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

This commit is contained in:
root
2026-06-29 08:54:41 -04:00
parent ae10f5272f
commit 3d6607e6c8
20 changed files with 3894 additions and 836 deletions
@@ -198,4 +198,74 @@ describe('menu.service', () => {
}) })
expect(result.items.find((item) => item.label === 'Reports')?.reasons.join(' ')).toContain('STARTER does not include this menu item') expect(result.items.find((item) => item.label === 'Reports')?.reasons.join(' ')).toContain('STARTER does not include this menu item')
}) })
it('returns the approved seven-item STARTER owner sidebar in order', async () => {
vi.mocked(prisma.employee.findUniqueOrThrow).mockResolvedValue({
id: 'employee_1',
role: 'OWNER',
isActive: true,
companyId: 'company_1',
} as never)
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({
id: 'company_1',
name: 'Atlas Cars',
status: 'ACTIVE',
subscription: { plan: 'STARTER', status: 'ACTIVE' },
} as never)
const item = (
systemKey: string,
label: string,
routeOrUrl: string,
icon: string,
displayOrder: number,
plans: string[],
) => ({
id: `item_${systemKey}`,
systemKey,
label,
itemType: 'INTERNAL_PAGE',
routeOrUrl,
icon,
parentId: null,
openInNewTab: false,
isRequired: displayOrder <= 70,
isActive: true,
displayOrder,
roleVisibilities: [{ role: 'OWNER' }],
subscriptionAssignments: plans.map((plan) => ({ plan, displayOrder, isActive: true })),
companyAssignments: [],
})
vi.mocked(prisma.menuItem.findMany).mockResolvedValue([
item('dashboard', 'Dashboard', '/', 'LayoutDashboard', 10, ['STARTER', 'GROWTH', 'PRO']),
item('reservations', 'Reservations', '/reservations', 'Calendar', 20, ['STARTER', 'GROWTH', 'PRO']),
item('fleet', 'Fleet', '/fleet', 'Car', 30, ['STARTER', 'GROWTH', 'PRO']),
item('customers', 'Customers', '/customers', 'Users', 40, ['STARTER', 'GROWTH', 'PRO']),
item('reports', 'Reports', '/reports', 'BarChart2', 50, ['STARTER', 'GROWTH', 'PRO']),
item('billing', 'Billing', '/billing', 'CreditCard', 60, ['STARTER', 'GROWTH', 'PRO']),
item('settings', 'Settings', '/settings', 'Settings', 70, ['STARTER', 'GROWTH', 'PRO']),
item('online-reservations', 'Online Reservations', '/online-reservations', 'Globe', 80, ['GROWTH', 'PRO']),
item('offers', 'Offers', '/offers', 'Tag', 90, ['GROWTH', 'PRO']),
item('team', 'Team', '/team', 'UserPlus', 100, ['GROWTH', 'PRO']),
item('contracts', 'Contracts', '/contracts', 'FileText', 110, ['GROWTH', 'PRO']),
item('notifications', 'Notifications', '/notifications', 'Bell', 120, ['GROWTH', 'PRO']),
item('reviews', 'Reviews', '/reviews', 'Star', 130, ['PRO']),
item('complaints', 'Complaints', '/complaints', 'AlertTriangle', 140, ['PRO']),
{ ...item('subscription', 'Subscription', '/subscription', 'CreditCard', 999, []), isActive: false },
] as never)
const result = await getEmployeeMenu('employee_1')
expect(result.items.map((menuItem) => menuItem.systemKey)).toEqual([
'dashboard',
'reservations',
'fleet',
'customers',
'reports',
'billing',
'settings',
])
})
}) })
@@ -1,808 +1,70 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider' import { useDashboardI18n } from '@/components/I18nProvider'
import { BilingualField, BilingualInput, bilingualPrimary, emptyBilingual } from '@/components/ui/BilingualInput' import { ReservationWizard } from '@/components/reservations/new/ReservationWizard'
import { getReservationWizardCopy } from '@/components/reservations/new/reservationWizard.copy'
type Customer = { import type { Customer, Vehicle } from '@/components/reservations/new/reservationWizard.types'
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 }
export default function NewReservationPage() { export default function NewReservationPage() {
const { language } = useDashboardI18n() const { language } = useDashboardI18n()
const router = useRouter() const copy = getReservationWizardCopy(language)
const [customers, setCustomers] = useState<Customer[]>([]) const [customers, setCustomers] = useState<Customer[]>([])
const [vehicles, setVehicles] = useState<Vehicle[]>([]) const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [customerLoadError, setCustomerLoadError] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [vehicleLoadError, setVehicleLoadError] = 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 lespace, 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]
useEffect(() => { useEffect(() => {
Promise.all([ let cancelled = false
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))
}, [])
const filteredCustomers = (() => { async function loadData() {
const q = customerSearch.trim().toLowerCase() setLoading(true)
if (!q) return customers const [customerResult, vehicleResult] = await Promise.allSettled([
return customers.filter((c) => apiFetch<Customer[]>('/customers?pageSize=100'),
`${c.firstName} ${c.lastName}`.toLowerCase().includes(q) || apiFetch<Vehicle[]>('/vehicles?pageSize=100'),
c.email.toLowerCase().includes(q), ])
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 ( return (
<div className="space-y-6 max-w-3xl"> <div className="space-y-4">
<div className="flex items-start justify-between gap-3"> {customerLoadError ? <div className="card max-w-3xl p-4 text-sm text-red-700">{customerLoadError}</div> : null}
<div> {vehicleLoadError ? <div className="card max-w-3xl p-4 text-sm text-red-700">{vehicleLoadError}</div> : null}
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2> <ReservationWizard customers={customers} vehicles={vehicles} language={language} />
<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 nest 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> </div>
) )
} }
+12 -9
View File
@@ -321,14 +321,15 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
nav: { nav: {
dashboard: 'Dashboard', dashboard: 'Dashboard',
fleet: 'Fleet', fleet: 'Fleet',
reservations: 'Booking', reservations: 'Reservations',
onlineReservations: 'Online Reservations', onlineReservations: 'Online Reservations',
'online-reservations': 'Online Reservations',
customers: 'Customers', customers: 'Customers',
offers: 'Offers', offers: 'Offers',
team: 'Team', team: 'Team',
reports: 'Reports', reports: 'Reports',
subscription: 'Subscription', subscription: 'Subscription',
billing: 'Customer Billing', billing: 'Billing',
contracts: 'Contracts', contracts: 'Contracts',
notifications: 'Notifications', notifications: 'Notifications',
settings: 'Settings', settings: 'Settings',
@@ -338,7 +339,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
titles: { titles: {
'/': 'Dashboard', '/': 'Dashboard',
'/fleet': 'Fleet Management', '/fleet': 'Fleet Management',
'/reservations': 'Booking', '/reservations': 'Reservations',
'/reservations/new': 'Book Car', '/reservations/new': 'Book Car',
'/online-reservations': 'Online Reservations', '/online-reservations': 'Online Reservations',
'/customers': 'Customers', '/customers': 'Customers',
@@ -346,7 +347,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
'/team': 'Team', '/team': 'Team',
'/reports': 'Reports', '/reports': 'Reports',
'/subscription': 'Subscription', '/subscription': 'Subscription',
'/billing': 'Customer Billing', '/billing': 'Billing',
'/contracts': 'Contracts', '/contracts': 'Contracts',
'/notifications': 'Notifications', '/notifications': 'Notifications',
'/settings': 'Settings', '/settings': 'Settings',
@@ -679,12 +680,13 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
fleet: 'Flotte', fleet: 'Flotte',
reservations: 'Réservations', reservations: 'Réservations',
onlineReservations: 'Réservations en ligne', onlineReservations: 'Réservations en ligne',
'online-reservations': 'Réservations en ligne',
customers: 'Clients', customers: 'Clients',
offers: 'Offres', offers: 'Offres',
team: 'Équipe', team: 'Équipe',
reports: 'Rapports', reports: 'Rapports',
subscription: 'Abonnement', subscription: 'Abonnement',
billing: 'Facturation clients', billing: 'Facturation',
contracts: 'Contrats', contracts: 'Contrats',
notifications: 'Notifications', notifications: 'Notifications',
settings: 'Paramètres', settings: 'Paramètres',
@@ -702,7 +704,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
'/team': 'Équipe', '/team': 'Équipe',
'/reports': 'Rapports', '/reports': 'Rapports',
'/subscription': 'Abonnement', '/subscription': 'Abonnement',
'/billing': 'Facturation clients', '/billing': 'Facturation',
'/contracts': 'Contrats', '/contracts': 'Contrats',
'/notifications': 'Notifications', '/notifications': 'Notifications',
'/settings': 'Paramètres', '/settings': 'Paramètres',
@@ -1034,13 +1036,14 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
dashboard: 'لوحة التحكم', dashboard: 'لوحة التحكم',
fleet: 'الأسطول', fleet: 'الأسطول',
reservations: 'الحجوزات', reservations: 'الحجوزات',
onlineReservations: 'الحجوزات الإلكترونية', onlineReservations: 'الحجوزات عبر الإنترنت',
'online-reservations': 'الحجوزات عبر الإنترنت',
customers: 'العملاء', customers: 'العملاء',
offers: 'العروض', offers: 'العروض',
team: 'الفريق', team: 'الفريق',
reports: 'التقارير', reports: 'التقارير',
subscription: 'الاشتراك', subscription: 'الاشتراك',
billing: 'فوترة العملاء', billing: 'الفوترة',
contracts: 'العقود', contracts: 'العقود',
notifications: 'الإشعارات', notifications: 'الإشعارات',
settings: 'الإعدادات', settings: 'الإعدادات',
@@ -1058,7 +1061,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
'/team': 'الفريق', '/team': 'الفريق',
'/reports': 'التقارير', '/reports': 'التقارير',
'/subscription': 'الاشتراك', '/subscription': 'الاشتراك',
'/billing': 'فوترة العملاء', '/billing': 'الفوترة',
'/contracts': 'العقود', '/contracts': 'العقود',
'/notifications': 'الإشعارات', '/notifications': 'الإشعارات',
'/settings': 'الإعدادات', '/settings': 'الإعدادات',
@@ -1,7 +1,19 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { hasRenderableMenuItems } from './Sidebar' import { APPROVED_BASELINE_MENU_KEYS, hasRenderableMenuItems } from './Sidebar'
describe('Sidebar menu rendering helpers', () => { describe('Sidebar menu rendering helpers', () => {
it('keeps the safe fallback menu to the approved seven baseline items in order', () => {
expect(APPROVED_BASELINE_MENU_KEYS).toEqual([
'dashboard',
'reservations',
'fleet',
'customers',
'reports',
'billing',
'settings',
])
})
it('treats an empty employee menu as non-renderable so fallback navigation remains available', () => { it('treats an empty employee menu as non-renderable so fallback navigation remains available', () => {
expect(hasRenderableMenuItems(null)).toBe(false) expect(hasRenderableMenuItems(null)).toBe(false)
expect(hasRenderableMenuItems([])).toBe(false) expect(hasRenderableMenuItems([])).toBe(false)
@@ -87,22 +87,16 @@ function toSidebarUser(profile: Partial<EmployeeProfile>, fallbackName: string,
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: '/', key: 'dashboard', icon: LayoutDashboard, exact: true, minRole: 'AGENT' }, { href: '/', key: 'dashboard', icon: LayoutDashboard, exact: true, minRole: 'AGENT' },
{ href: '/fleet', key: 'fleet', icon: Car, minRole: 'AGENT' },
{ href: '/reservations', key: 'reservations', icon: Calendar, minRole: 'AGENT' }, { href: '/reservations', key: 'reservations', icon: Calendar, minRole: 'AGENT' },
{ href: '/online-reservations', key: 'onlineReservations', icon: Globe, minRole: 'AGENT' }, { href: '/fleet', key: 'fleet', icon: Car, minRole: 'AGENT' },
{ href: '/customers', key: 'customers', icon: Users, minRole: 'AGENT' }, { href: '/customers', key: 'customers', icon: Users, minRole: 'AGENT' },
{ href: '/offers', key: 'offers', icon: Tag, minRole: 'MANAGER' },
{ href: '/team', key: 'team', icon: UserPlus, minRole: 'AGENT' },
{ href: '/reports', key: 'reports', icon: BarChart2, minRole: 'MANAGER' }, { href: '/reports', key: 'reports', icon: BarChart2, minRole: 'MANAGER' },
{ href: '/subscription', key: 'subscription', icon: CreditCard, minRole: 'OWNER' },
{ href: '/billing', key: 'billing', icon: CreditCard, minRole: 'MANAGER' }, { href: '/billing', key: 'billing', icon: CreditCard, minRole: 'MANAGER' },
{ href: '/contracts', key: 'contracts', icon: FileText, minRole: 'AGENT' },
{ href: '/reviews', key: 'reviews', icon: Star, minRole: 'AGENT' },
{ href: '/complaints', key: 'complaints', icon: AlertTriangle, minRole: 'AGENT' },
{ href: '/notifications', key: 'notifications', icon: Bell, minRole: 'AGENT' },
{ href: '/settings', key: 'settings', icon: Settings, minRole: 'OWNER' }, { href: '/settings', key: 'settings', icon: Settings, minRole: 'OWNER' },
] as const ] as const
export const APPROVED_BASELINE_MENU_KEYS = NAV_ITEMS.map((item) => item.key)
const ICON_MAP = { const ICON_MAP = {
LayoutDashboard, LayoutDashboard,
Car, Car,
@@ -156,6 +150,7 @@ export default function Sidebar() {
}) })
const [role, setRole] = useState<string>('AGENT') const [role, setRole] = useState<string>('AGENT')
const [menuItems, setMenuItems] = useState<GeneratedMenuItem[] | null>(null) const [menuItems, setMenuItems] = useState<GeneratedMenuItem[] | null>(null)
const [menuLoadState, setMenuLoadState] = useState<'loading' | 'loaded' | 'failed'>('loading')
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
@@ -229,7 +224,12 @@ export default function Sidebar() {
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee)) window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth)) setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth))
if (employee.role) setRole(employee.role) if (employee.role) setRole(employee.role)
if (menu?.items) setMenuItems(menu.items) if (menu) {
setMenuItems(menu.items)
setMenuLoadState('loaded')
} else {
setMenuLoadState('failed')
}
if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') { if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') {
// Only apply the server-side preference when the employee has no local preference // Only apply the server-side preference when the employee has no local preference
// stored yet — avoids overriding a language the user manually switched to. // stored yet — avoids overriding a language the user manually switched to.
@@ -243,6 +243,7 @@ export default function Sidebar() {
.catch(() => { .catch(() => {
if (cancelled) return if (cancelled) return
if (!cached) setUser(toSidebarUser({}, dict.workspaceUser, dict.localAuth)) if (!cached) setUser(toSidebarUser({}, dict.workspaceUser, dict.localAuth))
setMenuLoadState('failed')
}) })
return () => { cancelled = true } return () => { cancelled = true }
@@ -278,8 +279,8 @@ export default function Sidebar() {
children: [], children: [],
})) }))
const useGeneratedMenu = hasRenderableMenuItems(menuItems) const useGeneratedMenu = menuLoadState === 'loaded'
const resolvedMenuItems = useGeneratedMenu && menuItems ? menuItems : fallbackMenuItems const resolvedMenuItems = useGeneratedMenu ? (menuItems ?? []) : fallbackMenuItems
function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode { function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode {
return items.map((item) => { return items.map((item) => {
@@ -406,33 +407,7 @@ export default function Sidebar() {
</a> </a>
<nav className="flex-1 space-y-1.5 overflow-y-auto px-3 py-5"> <nav className="flex-1 space-y-1.5 overflow-y-auto px-3 py-5">
{useGeneratedMenu ? renderGeneratedMenu(resolvedMenuItems) : NAV_ITEMS.filter((item) => !mounted || hasMinRole(role, item.minRole)).map((item) => { {renderGeneratedMenu(resolvedMenuItems)}
const Icon = item.icon
const active = mounted && isActive(item)
return (
<Link
key={item.href}
href={item.href}
className={[
'relative flex items-center gap-3 rounded-lg px-3.5 py-3 text-sm font-medium transition-all duration-200',
active
? 'bg-blue-500/10 text-blue-950 shadow-[inset_2px_0_0_#3b82f6,0_0_18px_rgba(59,130,246,0.10)] dark:text-slate-50'
: 'text-slate-500 hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-slate-50',
].join(' ')}
>
<Icon className={['h-[18px] w-[18px] flex-shrink-0', active ? 'text-blue-700 dark:text-blue-300' : ''].join(' ')} />
{dict.nav[item.key]}
{item.key === 'dashboard' ? (
<span className="ml-auto rounded-full border border-blue-400/30 bg-blue-500/15 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:text-blue-300">
Live
</span>
) : null}
{item.key === 'notifications' && active ? (
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-orange-400 shadow-[0_0_10px_rgba(249,115,22,0.5)]" />
) : null}
</Link>
)
})}
</nav> </nav>
<div className="border-t border-blue-200/70 px-3 py-4 dark:border-blue-400/10"> <div className="border-t border-blue-200/70 px-3 py-4 dark:border-blue-400/10">
@@ -0,0 +1,126 @@
import { bilingualPrimary } from '@/components/ui/BilingualInput'
import type { ReservationWizardCopy } from './reservationWizard.copy'
import type { Customer, ReservationDraft, Vehicle, WizardStepId } from './reservationWizard.types'
function display(value: string | null | undefined, fallback: string) {
return value?.trim() ? value : fallback
}
export function ReservationReview({
copy,
draft,
customers,
vehicles,
localeCode,
onEdit,
}: {
copy: ReservationWizardCopy
draft: ReservationDraft
customers: Customer[]
vehicles: Vehicle[]
localeCode: string
onEdit: (step: WizardStepId) => void
}) {
const selectedCustomer = customers.find((customer) => customer.id === draft.customer.selectedCustomerId)
const selectedVehicle = vehicles.find((vehicle) => vehicle.id === draft.rental.vehicleId)
const formatDateTime = (value: string) => value ? new Date(value).toLocaleString(localeCode, { dateStyle: 'medium', timeStyle: 'short' }) : copy.summaryEmpty
const sections: Array<{ step: WizardStepId; title: string; rows: Array<[string, string]> }> = [
{
step: 'customer',
title: copy.customer,
rows: [
[copy.customer, draft.customer.mode === 'existing' ? display(selectedCustomer ? `${selectedCustomer.firstName} ${selectedCustomer.lastName}` : '', copy.summaryEmpty) : `${bilingualPrimary(draft.customer.firstName)} ${bilingualPrimary(draft.customer.lastName)}`],
[copy.email, display(draft.customer.email, copy.summaryEmpty)],
[copy.phone, display(draft.customer.phone, copy.summaryEmpty)],
],
},
{
step: 'identity',
title: copy.identity,
rows: [
[copy.dateOfBirth, display(draft.identity.dateOfBirth, copy.summaryEmpty)],
[copy.nationality, display(draft.identity.nationality, copy.summaryEmpty)],
[copy.identityDocumentNumber, display(draft.identity.identityDocumentNumber, copy.summaryEmpty)],
[copy.fullAddress, display(draft.identity.fullAddress, copy.summaryEmpty)],
[copy.internationalLicenseNumber, display(draft.identity.internationalLicenseNumber, copy.summaryEmpty)],
],
},
{
step: 'license',
title: copy.license,
rows: [
[copy.driverLicenseNumber, display(draft.license.number, copy.summaryEmpty)],
[copy.licenseCountry, display(draft.license.country, copy.summaryEmpty)],
[copy.licenseIssuedAt, display(draft.license.issuedAt, copy.summaryEmpty)],
[copy.licenseExpiry, display(draft.license.expiry, copy.summaryEmpty)],
[copy.licenseCategory, display(draft.license.category, copy.summaryEmpty)],
[copy.licenseImage, draft.license.newImageFile ? draft.license.newImageFile.name : draft.license.existingImageUrl ? copy.licenseImageCurrent : copy.noLicenseImage],
],
},
{
step: 'rental',
title: copy.rental,
rows: [
[copy.vehicle, selectedVehicle ? `${selectedVehicle.make} ${selectedVehicle.model} · ${selectedVehicle.licensePlate}` : copy.summaryEmpty],
[copy.startDate, formatDateTime(draft.rental.startDate)],
[copy.endDate, formatDateTime(draft.rental.endDate)],
[copy.pickup, display(draft.rental.pickupLocation, copy.summaryEmpty)],
[copy.return, display(draft.rental.returnLocation, copy.summaryEmpty)],
],
},
{
step: 'payment',
title: copy.payment,
rows: [
[copy.deposit, display(draft.payment.depositAmount, copy.summaryEmpty)],
[copy.paymentMode, copy.paymentModes[draft.payment.paymentMode] ?? draft.payment.paymentMode],
[copy.additionalDriverInfo, draft.additionalDrivers.length ? String(draft.additionalDrivers.length) : copy.summaryEmpty],
[copy.notes, display(draft.payment.notes, copy.summaryEmpty)],
],
},
]
return (
<div className="space-y-4">
{sections.map((section) => (
<section key={section.step} className="rounded-lg border border-slate-200 p-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-slate-900">{section.title}</h3>
<button type="button" className="text-sm font-semibold text-blue-700 hover:underline" onClick={() => onEdit(section.step)}>
{copy.edit}
</button>
</div>
<dl className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
{section.rows.map(([label, value]) => (
<div key={`${section.step}-${label}`}>
<dt className="text-xs font-medium text-slate-500">{label}</dt>
<dd className="mt-0.5 text-sm text-slate-900">{value}</dd>
</div>
))}
</dl>
</section>
))}
{draft.additionalDrivers.map((driver, index) => (
<section key={driver.id} className="rounded-lg border border-slate-200 p-4">
<h3 className="text-sm font-semibold text-slate-900">
{copy.additionalDriverInfo} {index + 1}
</h3>
<dl className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<dt className="text-xs font-medium text-slate-500">{copy.firstName}</dt>
<dd className="mt-0.5 text-sm text-slate-900">{display(bilingualPrimary(driver.firstName), copy.summaryEmpty)}</dd>
</div>
<div>
<dt className="text-xs font-medium text-slate-500">{copy.lastName}</dt>
<dd className="mt-0.5 text-sm text-slate-900">{display(bilingualPrimary(driver.lastName), copy.summaryEmpty)}</dd>
</div>
<div>
<dt className="text-xs font-medium text-slate-500">{copy.driverLicenseNumber}</dt>
<dd className="mt-0.5 text-sm text-slate-900">{display(driver.driverLicense, copy.summaryEmpty)}</dd>
</div>
</dl>
</section>
))}
</div>
)
}
@@ -0,0 +1,59 @@
import { wizardSteps, type ReservationWizardCopy } from './reservationWizard.copy'
import type { WizardStepId } from './reservationWizard.types'
export function ReservationStepper({
copy,
currentStep,
completedSteps,
highestReachableIndex,
onStepClick,
}: {
copy: ReservationWizardCopy
currentStep: WizardStepId
completedSteps: Set<WizardStepId>
highestReachableIndex: number
onStepClick: (step: WizardStepId) => void
}) {
const currentIndex = wizardSteps.findIndex((step) => step.id === currentStep)
const currentLabel = copy[wizardSteps[currentIndex].labelKey] as string
return (
<div className="space-y-3">
<div className="md:hidden">
<p className="text-sm font-semibold text-slate-900">{copy.stepOf(currentIndex + 1, wizardSteps.length, currentLabel)}</p>
<div className="mt-2 h-2 rounded-full bg-slate-200">
<div
className="h-2 rounded-full bg-blue-700 transition-all"
style={{ width: `${((currentIndex + 1) / wizardSteps.length) * 100}%` }}
/>
</div>
</div>
<ol className="hidden grid-cols-6 gap-2 md:grid">
{wizardSteps.map((step, index) => {
const active = step.id === currentStep
const complete = completedSteps.has(step.id)
const disabled = index > highestReachableIndex
return (
<li key={step.id}>
<button
type="button"
aria-current={active ? 'step' : undefined}
disabled={disabled}
onClick={() => onStepClick(step.id)}
className={[
'h-full w-full rounded-lg border px-3 py-3 text-left text-sm transition',
active ? 'border-blue-700 bg-blue-50 text-blue-900' : complete ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : 'border-slate-200 bg-white text-slate-500',
disabled ? 'cursor-not-allowed opacity-50' : 'hover:border-blue-300',
].join(' ')}
>
<span className="block text-xs font-semibold">{index + 1}</span>
<span className="mt-1 block font-medium">{copy[step.labelKey] as string}</span>
</button>
</li>
)
})}
</ol>
</div>
)
}
@@ -0,0 +1,710 @@
'use client'
import { useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { BilingualInput, bilingualPrimary } from '@/components/ui/BilingualInput'
import { getReservationWizardCopy, wizardSteps } from './reservationWizard.copy'
import { ReservationReview } from './ReservationReview'
import { ReservationStepper } from './ReservationStepper'
import { createInitialDraft, reservationDraftReducer } from './reservationWizard.reducer'
import {
validateCompleteReservation,
validateCustomerStep,
validateIdentityStep,
validateLicenseStep,
validatePaymentExtrasStep,
validateRentalStep,
} from './reservationWizard.schemas'
import { checkVehicleAvailability, submitReservationDraft } from './reservationWizard.submit'
import type { AdditionalDriverDraft, Customer, FieldErrors, ReservationDraft, Vehicle, WizardStepId } from './reservationWizard.types'
import type { BilingualField } from '@/components/ui/BilingualInput'
type Language = 'en' | 'fr' | 'ar'
const paymentModes = ['CASH', 'CARD', 'BANK_TRANSFER', 'AMANPAY', 'PAYPAL'] as const
export function ReservationWizard({
customers,
vehicles,
language,
}: {
customers: Customer[]
vehicles: Vehicle[]
language: Language
}) {
const router = useRouter()
const copy = useMemo(() => getReservationWizardCopy(language), [language])
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [draft, dispatch] = useReducer(reservationDraftReducer, undefined, createInitialDraft)
const [currentStep, setCurrentStep] = useState<WizardStepId>('customer')
const [completedSteps, setCompletedSteps] = useState<Set<WizardStepId>>(new Set())
const [errors, setErrors] = useState<FieldErrors>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [customerSearch, setCustomerSearch] = useState('')
const [saving, setSaving] = useState(false)
const [licenseImagePreviewUrl, setLicenseImagePreviewUrl] = useState<string | null>(null)
const headingRef = useRef<HTMLHeadingElement | null>(null)
const dirtyRef = useRef(false)
const pendingErrorFocusRef = useRef<string | null>(null)
const stepIndex = wizardSteps.findIndex((step) => step.id === currentStep)
const highestReachableIndex = Math.min(completedSteps.size, wizardSteps.length - 1)
const availableVehicles = vehicles.filter((vehicle) => vehicle.status === 'AVAILABLE')
const filteredCustomers = useMemo(() => {
const q = customerSearch.trim().toLowerCase()
if (!q) return customers
return customers.filter((customer) =>
`${customer.firstName} ${customer.lastName}`.toLowerCase().includes(q) ||
customer.email.toLowerCase().includes(q),
)
}, [customerSearch, customers])
useEffect(() => {
dirtyRef.current = JSON.stringify(draft) !== JSON.stringify(createInitialDraft())
}, [draft])
useEffect(() => {
const onBeforeUnload = (event: BeforeUnloadEvent) => {
if (!dirtyRef.current) return
event.preventDefault()
event.returnValue = copy.leaveWarning
}
window.addEventListener('beforeunload', onBeforeUnload)
return () => window.removeEventListener('beforeunload', onBeforeUnload)
}, [copy.leaveWarning])
useEffect(() => {
headingRef.current?.focus()
}, [currentStep])
useEffect(() => {
if (!pendingErrorFocusRef.current) return
const field = pendingErrorFocusRef.current
requestAnimationFrame(() => {
const target = document.querySelector<HTMLElement>(`[data-field="${field}"]`)
if (target) {
target.focus()
pendingErrorFocusRef.current = null
}
})
}, [currentStep, errors])
useEffect(() => {
if (!draft.license.newImageFile) {
setLicenseImagePreviewUrl(null)
return
}
const objectUrl = URL.createObjectURL(draft.license.newImageFile)
setLicenseImagePreviewUrl(objectUrl)
return () => URL.revokeObjectURL(objectUrl)
}, [draft.license.newImageFile])
function setFieldErrors(nextErrors: FieldErrors) {
setErrors(nextErrors)
if (Object.keys(nextErrors).length === 0) return
pendingErrorFocusRef.current = Object.keys(nextErrors)[0]
requestAnimationFrame(() => {
if (!pendingErrorFocusRef.current) return
const target = document.querySelector<HTMLElement>(`[data-field="${pendingErrorFocusRef.current}"]`)
if (target) {
target.focus()
pendingErrorFocusRef.current = null
}
})
}
function updateCustomer(field: keyof ReservationDraft['customer'], value: any) {
dispatch({ type: 'updateCustomer', field, value })
clearFieldError(`customer.${field}`)
}
function clearFieldError(field: string) {
setErrors((current) => {
if (!current[field]) return current
const next = { ...current }
delete next[field]
return next
})
}
function updateIdentity(field: keyof ReservationDraft['identity'], value: string) {
dispatch({ type: 'updateIdentity', field, value })
clearFieldError(`identity.${field}`)
}
function updateLicense(field: keyof ReservationDraft['license'], value: any) {
dispatch({ type: 'updateLicense', field, value })
clearFieldError(`license.${field}`)
}
function updateRental(field: keyof ReservationDraft['rental'], value: string) {
dispatch({ type: 'updateRental', field, value })
clearFieldError(`rental.${field}`)
}
function updatePayment(field: keyof ReservationDraft['payment'], value: string) {
dispatch({ type: 'updatePayment', field, value })
clearFieldError(`payment.${field}`)
}
function selectCustomer(customerId: string) {
if (!customerId) return
const customer = customers.find((item) => item.id === customerId)
if (!customer) return
if (draft.customer.hydratedFromCustomerId && draft.customer.hydratedFromCustomerId !== customer.id) {
const confirmed = window.confirm(copy.confirmReplaceCustomer)
if (!confirmed) return
}
dispatch({ type: 'selectExistingCustomer', customer })
setCustomerSearch(`${customer.firstName} ${customer.lastName}`)
setErrors({})
}
function validateStep(step: WizardStepId) {
if (step === 'customer') return validateCustomerStep(draft, copy)
if (step === 'identity') return validateIdentityStep(draft, copy)
if (step === 'license') return validateLicenseStep(draft, copy)
if (step === 'rental') return validateRentalStep(draft, copy)
if (step === 'payment') return validatePaymentExtrasStep(draft, copy)
return validateCompleteReservation(draft, copy)
}
async function handleContinue() {
setSubmitError(null)
const nextErrors = validateStep(currentStep)
if (Object.keys(nextErrors).length > 0) {
setFieldErrors(nextErrors)
return
}
if (currentStep === 'rental') {
try {
const availability = await checkVehicleAvailability(draft)
if (!availability.available) {
setFieldErrors({ 'rental.vehicleId': copy.availabilityConflict })
return
}
} catch (err: any) {
setFieldErrors({ 'rental.vehicleId': err.message ?? copy.availabilityConflict })
return
}
}
const nextCompleted = new Set(completedSteps)
nextCompleted.add(currentStep)
setCompletedSteps(nextCompleted)
const nextStep = wizardSteps[Math.min(stepIndex + 1, wizardSteps.length - 1)]
setErrors({})
setCurrentStep(nextStep.id)
}
function handleBack() {
if (stepIndex === 0) {
handleCancel()
return
}
setErrors({})
setCurrentStep(wizardSteps[stepIndex - 1].id)
}
function handleCancel() {
router.push('/reservations')
}
function handleStepClick(step: WizardStepId) {
const targetIndex = wizardSteps.findIndex((item) => item.id === step)
if (targetIndex > highestReachableIndex) return
setErrors({})
setCurrentStep(step)
}
async function handleSubmit() {
if (saving) {
setSubmitError(copy.duplicateGuard)
return
}
const nextErrors = validateCompleteReservation(draft, copy)
if (Object.keys(nextErrors).length > 0) {
const firstStepWithError = wizardSteps.find((step) =>
Object.keys(nextErrors).some((field) => {
if (step.id === 'customer') return field.startsWith('customer.')
if (step.id === 'identity') return field.startsWith('identity.')
if (step.id === 'license') return field.startsWith('license.')
if (step.id === 'rental') return field.startsWith('rental.')
if (step.id === 'payment') return field.startsWith('payment.') || field.startsWith('additionalDrivers.')
return false
}),
)
if (firstStepWithError) setCurrentStep(firstStepWithError.id)
setFieldErrors(nextErrors)
return
}
setSaving(true)
setSubmitError(null)
try {
const result = await submitReservationDraft(draft, {
onCustomerIdResolved: (customerId) => dispatch({ type: 'setCreatedCustomerId', customerId }),
})
dirtyRef.current = false
dispatch({ type: 'reset' })
router.push(`/reservations/${result.reservation.id}`)
} catch (err: any) {
setSubmitError(err.code === 'vehicle_unavailable' ? copy.availabilityConflict : err.message ?? copy.submitFailed)
} finally {
setSaving(false)
}
}
const currentLabel = copy[wizardSteps[stepIndex].labelKey] as string
return (
<div className="max-w-4xl space-y-6" dir={language === 'ar' ? 'rtl' : 'ltr'}>
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
<ReservationStepper
copy={copy}
currentStep={currentStep}
completedSteps={completedSteps}
highestReachableIndex={highestReachableIndex}
onStepClick={handleStepClick}
/>
{submitError ? <div className="card p-4 text-sm text-red-700">{submitError}</div> : null}
<form
className="card p-6"
onSubmit={(event) => {
event.preventDefault()
if (currentStep === 'review') void handleSubmit()
}}
>
<h3 ref={headingRef} tabIndex={-1} className="mb-5 text-lg font-semibold text-slate-900 outline-none">
{currentLabel}
</h3>
{currentStep === 'customer' ? (
<CustomerStep
copy={copy}
draft={draft}
customers={filteredCustomers}
customerSearch={customerSearch}
errors={errors}
onSearchChange={setCustomerSearch}
onSelectCustomer={selectCustomer}
onModeChange={(mode: 'existing' | 'new') => {
dispatch({ type: 'setCustomerMode', mode })
setErrors({})
}}
onCustomerChange={updateCustomer}
/>
) : null}
{currentStep === 'identity' ? (
<IdentityStep copy={copy} draft={draft} errors={errors} onChange={updateIdentity} />
) : null}
{currentStep === 'license' ? (
<LicenseStep
copy={copy}
draft={draft}
errors={errors}
previewUrl={licenseImagePreviewUrl}
onChange={updateLicense}
/>
) : null}
{currentStep === 'rental' ? (
<RentalStep
copy={copy}
draft={draft}
vehicles={vehicles}
availableVehicles={availableVehicles}
errors={errors}
onChange={updateRental}
/>
) : null}
{currentStep === 'payment' ? (
<PaymentStep
copy={copy}
draft={draft}
errors={errors}
onPaymentChange={updatePayment}
onAddDriver={() => dispatch({ type: 'addAdditionalDriver' })}
onRemoveDriver={(id: string) => dispatch({ type: 'removeAdditionalDriver', id })}
onDriverChange={(id: string, field: keyof AdditionalDriverDraft, value: any) => {
dispatch({ type: 'updateAdditionalDriver', id, field, value })
setErrors((current) => {
const index = draft.additionalDrivers.findIndex((driver) => driver.id === id)
const key = `additionalDrivers.${index}.${field}`
if (!current[key]) return current
const next = { ...current }
delete next[key]
return next
})
}}
/>
) : null}
{currentStep === 'review' ? (
<ReservationReview
copy={copy}
draft={draft}
customers={customers}
vehicles={vehicles}
localeCode={localeCode}
onEdit={setCurrentStep}
/>
) : null}
<div className="sticky bottom-0 mt-6 flex items-center justify-between gap-3 border-t border-slate-200 bg-white pt-4">
<div className="flex items-center gap-3">
<button type="button" className="btn-secondary" onClick={handleBack} disabled={saving}>
{stepIndex === 0 ? copy.cancel : copy.back}
</button>
{currentStep === 'review' ? (
<button type="button" className="btn-secondary" onClick={handleCancel} disabled={saving}>
{copy.cancel}
</button>
) : null}
</div>
{currentStep === 'review' ? (
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? copy.creating : copy.create}
</button>
) : (
<button type="button" className="btn-primary" onClick={handleContinue}>
{copy.continue}
</button>
)}
</div>
</form>
</div>
)
}
function FieldError({ id, errors }: { id: string; errors: FieldErrors }) {
return errors[id] ? <p id={`${id}-error`} className="text-xs text-red-700">{errors[id]}</p> : null
}
function inputProps(field: string, errors: FieldErrors) {
return {
'data-field': field,
'aria-invalid': Boolean(errors[field]),
'aria-describedby': errors[field] ? `${field}-error` : undefined,
}
}
function CustomerStep({
copy,
draft,
customers,
customerSearch,
errors,
onSearchChange,
onSelectCustomer,
onModeChange,
onCustomerChange,
}: {
copy: ReturnType<typeof getReservationWizardCopy>
draft: ReservationDraft
customers: Customer[]
customerSearch: string
errors: FieldErrors
onSearchChange: (value: string) => void
onSelectCustomer: (customerId: string) => void
onModeChange: (mode: 'existing' | 'new') => void
onCustomerChange: (field: keyof ReservationDraft['customer'], value: any) => void
}) {
return (
<div className="space-y-5">
<div className="flex flex-wrap gap-2">
{(['existing', 'new'] as const).map((mode) => (
<button
key={mode}
type="button"
className={draft.customer.mode === mode ? 'btn-primary' : 'btn-secondary'}
onClick={() => onModeChange(mode)}
>
{copy.modes[mode]}
</button>
))}
</div>
{draft.customer.mode === 'existing' ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.searchCustomer}</span>
<input value={customerSearch} onChange={(event) => onSearchChange(event.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.customer} <span className="text-red-600">*</span></span>
<select
value={draft.customer.selectedCustomerId ?? ''}
onChange={(event) => onSelectCustomer(event.target.value)}
className="input-field"
{...inputProps('customer.selectedCustomerId', errors)}
>
<option value="">{copy.selectCustomer}</option>
{customers.map((customer: Customer) => (
<option key={customer.id} value={customer.id}>{customer.firstName} {customer.lastName} · {customer.email}</option>
))}
</select>
<FieldError id="customer.selectedCustomerId" errors={errors} />
</label>
</div>
) : (
<div className="space-y-4">
<div data-field="customer.firstName" tabIndex={-1}>
<BilingualInput label={copy.firstName} required value={draft.customer.firstName} onChange={(value: BilingualField) => onCustomerChange('firstName', value)} />
<FieldError id="customer.firstName" errors={errors} />
</div>
<div data-field="customer.lastName" tabIndex={-1}>
<BilingualInput label={copy.lastName} required value={draft.customer.lastName} onChange={(value: BilingualField) => onCustomerChange('lastName', value)} />
<FieldError id="customer.lastName" errors={errors} />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.email} <span className="text-red-600">*</span></span>
<input value={draft.customer.email} onChange={(event) => onCustomerChange('email', event.target.value)} className="input-field" {...inputProps('customer.email', errors)} />
<FieldError id="customer.email" errors={errors} />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.phone} <span className="text-red-600">*</span></span>
<input value={draft.customer.phone} onChange={(event) => onCustomerChange('phone', event.target.value)} className="input-field" {...inputProps('customer.phone', errors)} />
<FieldError id="customer.phone" errors={errors} />
</label>
</div>
</div>
)}
</div>
)
}
function IdentityStep({
copy,
draft,
errors,
onChange,
}: {
copy: ReturnType<typeof getReservationWizardCopy>
draft: ReservationDraft
errors: FieldErrors
onChange: (field: keyof ReservationDraft['identity'], value: string) => void
}) {
return (
<div className="space-y-4">
{draft.customer.hydratedFromCustomerId ? <p className="text-sm text-slate-500">{copy.loadedCustomerData}</p> : null}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField type="date" id="identity.dateOfBirth" label={copy.dateOfBirth} required value={draft.identity.dateOfBirth} errors={errors} onChange={(value) => onChange('dateOfBirth', value)} />
<TextField id="identity.nationality" label={copy.nationality} required value={draft.identity.nationality} errors={errors} onChange={(value) => onChange('nationality', value)} />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField id="identity.identityDocumentNumber" label={copy.identityDocumentNumber} required value={draft.identity.identityDocumentNumber} errors={errors} onChange={(value) => onChange('identityDocumentNumber', value)} />
<TextField id="identity.internationalLicenseNumber" label={copy.internationalLicenseNumber} value={draft.identity.internationalLicenseNumber} errors={errors} onChange={(value) => onChange('internationalLicenseNumber', value)} />
</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={draft.identity.fullAddress} onChange={(event) => onChange('fullAddress', event.target.value)} className="input-field min-h-[96px]" {...inputProps('identity.fullAddress', errors)} />
<FieldError id="identity.fullAddress" errors={errors} />
</label>
</div>
)
}
function LicenseStep({
copy,
draft,
errors,
previewUrl,
onChange,
}: {
copy: ReturnType<typeof getReservationWizardCopy>
draft: ReservationDraft
errors: FieldErrors
previewUrl: string | null
onChange: (field: keyof ReservationDraft['license'], value: any) => void
}) {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField id="license.number" label={copy.driverLicenseNumber} required value={draft.license.number} errors={errors} onChange={(value) => onChange('number', value)} />
<TextField id="license.country" label={copy.licenseCountry} required value={draft.license.country} errors={errors} onChange={(value) => onChange('country', value)} />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<TextField type="date" id="license.issuedAt" label={copy.licenseIssuedAt} required value={draft.license.issuedAt} errors={errors} onChange={(value) => onChange('issuedAt', value)} />
<TextField type="date" id="license.expiry" label={copy.licenseExpiry} required value={draft.license.expiry} errors={errors} onChange={(value) => onChange('expiry', value)} />
<TextField id="license.category" label={copy.licenseCategory} required value={draft.license.category} errors={errors} onChange={(value) => onChange('category', value)} />
</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={(event) => onChange('newImageFile', event.target.files?.[0] ?? null)} className="input-field" {...inputProps('license.newImageFile', errors)} />
<FieldError id="license.newImageFile" errors={errors} />
</label>
<p className="text-xs text-slate-500">{copy.licenseImageHint}</p>
{draft.license.newImageFile ? (
<p className="text-xs font-medium text-slate-700">{copy.licenseImageSelected}: {draft.license.newImageFile.name}</p>
) : draft.license.existingImageUrl ? (
<p className="text-xs font-medium text-slate-700">{copy.licenseImageCurrent}</p>
) : (
<p className="text-xs text-slate-500">{copy.noLicenseImage}</p>
)}
{previewUrl || draft.license.existingImageUrl ? (
<img src={previewUrl ?? draft.license.existingImageUrl ?? ''} alt={copy.licenseImageAlt} className="h-40 w-full max-w-sm rounded-lg border border-slate-200 object-cover" />
) : null}
</div>
</div>
)
}
function RentalStep({
copy,
draft,
vehicles,
availableVehicles,
errors,
onChange,
}: {
copy: ReturnType<typeof getReservationWizardCopy>
draft: ReservationDraft
vehicles: Vehicle[]
availableVehicles: Vehicle[]
errors: FieldErrors
onChange: (field: keyof ReservationDraft['rental'], value: string) => void
}) {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField type="datetime-local" id="rental.startDate" label={copy.startDate} required value={draft.rental.startDate} errors={errors} onChange={(value) => onChange('startDate', value)} />
<TextField type="datetime-local" id="rental.endDate" label={copy.endDate} required value={draft.rental.endDate} errors={errors} onChange={(value) => onChange('endDate', value)} />
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.vehicle} <span className="text-red-600">*</span></span>
<select value={draft.rental.vehicleId} onChange={(event) => onChange('vehicleId', event.target.value)} className="input-field" {...inputProps('rental.vehicleId', errors)}>
<option value="">{copy.selectVehicle}</option>
{availableVehicles.map((vehicle: Vehicle) => (
<option key={vehicle.id} value={vehicle.id}>{vehicle.make} {vehicle.model} · {vehicle.licensePlate}</option>
))}
</select>
<FieldError id="rental.vehicleId" errors={errors} />
{vehicles.length === 0 ? <span className="text-xs text-orange-700">{copy.noVehicles}</span> : null}
{vehicles.length > 0 && availableVehicles.length === 0 ? <span className="text-xs text-orange-700">{copy.noAvailableStatusVehicles}</span> : null}
</label>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField id="rental.pickupLocation" label={copy.pickup} value={draft.rental.pickupLocation} errors={errors} onChange={(value) => onChange('pickupLocation', value)} />
<TextField id="rental.returnLocation" label={copy.return} value={draft.rental.returnLocation} errors={errors} onChange={(value) => onChange('returnLocation', value)} />
</div>
</div>
)
}
function PaymentStep({
copy,
draft,
errors,
onPaymentChange,
onAddDriver,
onRemoveDriver,
onDriverChange,
}: {
copy: ReturnType<typeof getReservationWizardCopy>
draft: ReservationDraft
errors: FieldErrors
onPaymentChange: (field: keyof ReservationDraft['payment'], value: string) => void
onAddDriver: () => void
onRemoveDriver: (id: string) => void
onDriverChange: (id: string, field: keyof AdditionalDriverDraft, value: any) => void
}) {
return (
<div className="space-y-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField type="number" id="payment.depositAmount" label={copy.deposit} value={draft.payment.depositAmount} errors={errors} min={0} onChange={(value) => onPaymentChange('depositAmount', value)} />
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.paymentMode} <span className="text-red-600">*</span></span>
<select value={draft.payment.paymentMode} onChange={(event) => onPaymentChange('paymentMode', event.target.value)} className="input-field" {...inputProps('payment.paymentMode', errors)}>
{paymentModes.map((mode) => <option key={mode} value={mode}>{copy.paymentModes[mode]}</option>)}
</select>
<FieldError id="payment.paymentMode" errors={errors} />
</label>
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.notes}</span>
<textarea value={draft.payment.notes} onChange={(event) => onPaymentChange('notes', event.target.value)} className="input-field min-h-[96px]" placeholder={copy.notesPlaceholder} />
</label>
<div className="rounded-lg border border-slate-200 p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-slate-900">{copy.additionalDriverInfo}</p>
<button type="button" className="btn-secondary" onClick={onAddDriver}>{copy.addAnotherDriver}</button>
</div>
<div className="mt-4 space-y-5">
{draft.additionalDrivers.map((driver, index) => (
<div key={driver.id} className="space-y-4 rounded-lg border border-slate-200 p-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900">{copy.additionalDriverInfo} {index + 1}</p>
<button type="button" className="text-sm font-semibold text-red-700 hover:underline" onClick={() => onRemoveDriver(driver.id)}>{copy.remove}</button>
</div>
<div data-field={`additionalDrivers.${index}.firstName`} tabIndex={-1}>
<BilingualInput label={copy.firstName} required value={driver.firstName} onChange={(value: BilingualField) => onDriverChange(driver.id, 'firstName', value)} />
<FieldError id={`additionalDrivers.${index}.firstName`} errors={errors} />
</div>
<div data-field={`additionalDrivers.${index}.lastName`} tabIndex={-1}>
<BilingualInput label={copy.lastName} required value={driver.lastName} onChange={(value: BilingualField) => onDriverChange(driver.id, 'lastName', value)} />
<FieldError id={`additionalDrivers.${index}.lastName`} errors={errors} />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField id={`additionalDrivers.${index}.email`} label={copy.email} value={driver.email} errors={errors} onChange={(value) => onDriverChange(driver.id, 'email', value)} />
<TextField id={`additionalDrivers.${index}.phone`} label={copy.phone} value={driver.phone} errors={errors} onChange={(value) => onDriverChange(driver.id, 'phone', value)} />
<TextField id={`additionalDrivers.${index}.driverLicense`} label={copy.driverLicenseNumber} required value={driver.driverLicense} errors={errors} onChange={(value) => onDriverChange(driver.id, 'driverLicense', value)} />
<div data-field={`additionalDrivers.${index}.nationality`} tabIndex={-1}>
<BilingualInput label={copy.nationality} value={driver.nationality} onChange={(value: BilingualField) => onDriverChange(driver.id, 'nationality', value)} />
</div>
<TextField type="date" id={`additionalDrivers.${index}.dateOfBirth`} label={copy.dateOfBirth} value={driver.dateOfBirth} errors={errors} onChange={(value) => onDriverChange(driver.id, 'dateOfBirth', value)} />
<TextField type="date" id={`additionalDrivers.${index}.licenseIssuedAt`} label={copy.licenseIssuedAt} value={driver.licenseIssuedAt} errors={errors} onChange={(value) => onDriverChange(driver.id, 'licenseIssuedAt', value)} />
<TextField type="date" id={`additionalDrivers.${index}.licenseExpiry`} label={copy.licenseExpiry} value={driver.licenseExpiry} errors={errors} onChange={(value) => onDriverChange(driver.id, 'licenseExpiry', value)} />
</div>
</div>
))}
</div>
</div>
</div>
)
}
function TextField({
id,
label,
value,
errors,
onChange,
required = false,
type = 'text',
min,
}: {
id: string
label: string
value: string
errors: FieldErrors
onChange: (value: string) => void
required?: boolean
type?: string
min?: number
}) {
return (
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">
{label}
{required ? <span className="text-red-600"> *</span> : null}
</span>
<input type={type} min={min} value={value} onChange={(event) => onChange(event.target.value)} className="input-field" {...inputProps(id, errors)} />
<FieldError id={id} errors={errors} />
</label>
)
}
@@ -0,0 +1,269 @@
import type { WizardStepId } from './reservationWizard.types'
type Language = 'en' | 'fr' | 'ar'
export function getReservationWizardCopy(language: Language) {
return {
en: {
title: 'Local Booking',
subtitle: 'Create a reservation directly from workspace without a previous online reservation.',
loading: 'Loading reservation data...',
cancel: 'Cancel',
back: 'Back',
continue: 'Continue',
create: 'Create booking',
creating: 'Creating...',
edit: 'Edit',
remove: 'Remove',
addAnotherDriver: 'Add another driver',
customer: 'Customer',
identity: 'Identity',
license: 'Driver license',
rental: 'Rental details',
payment: 'Payment and extras',
review: 'Review',
stepOf: (current: number, total: number, label: string) => `Step ${current} of ${total} · ${label}`,
modes: { existing: 'Existing customer', new: 'New customer' },
searchCustomer: 'Search previous customer',
selectCustomer: 'Select customer...',
firstName: 'First name',
lastName: 'Last name',
email: 'Email',
phone: 'Phone',
dateOfBirth: 'Date of birth',
nationality: 'Nationality',
fullAddress: 'Full address',
identityDocumentNumber: 'CIN / Passport number',
internationalLicenseNumber: 'International permit number',
loadedCustomerData: 'Loaded from selected customer. You can correct these values before creating the reservation.',
driverLicenseNumber: 'Driver license number',
licenseCountry: 'License country',
licenseIssuedAt: 'License issued at',
licenseExpiry: 'License expiry',
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.',
licenseImageAlt: 'Driver license preview',
startDate: 'Start date & time',
endDate: 'End date & time',
vehicle: 'Vehicle',
selectVehicle: 'Select vehicle...',
pickup: 'Pickup location',
return: 'Return location',
noVehicles: 'No available vehicles.',
noAvailableStatusVehicles: 'Vehicles exist in fleet, but none are marked Available.',
availabilityConflict: 'The selected vehicle is not available for those dates.',
deposit: 'Deposit amount (MAD)',
paymentMode: 'Payment mode',
notes: 'Notes',
notesPlaceholder: 'Optional notes...',
additionalDriverInfo: 'Additional driver',
summaryEmpty: 'Not provided',
loadFailed: 'Failed to load customers/vehicles.',
submitFailed: 'Reservation creation failed. Your draft is still available for correction or retry.',
duplicateGuard: 'Reservation creation is already in progress.',
confirmReplaceCustomer: 'Replace the loaded customer details with the newly selected customer?',
leaveWarning: 'You have an unfinished reservation draft.',
validation: {
required: 'This field is required.',
email: 'Enter a valid email address.',
pastDate: 'Date must be in the past.',
futureIssue: 'Issue date cannot be in the future.',
expiryAfterIssue: 'Expiry date must be after the issue date.',
expiryFuture: 'Expiry date must be in the future.',
endAfterStart: 'End date must be after start date.',
deposit: 'Deposit cannot be negative.',
imageType: 'Upload a supported image file.',
imageSize: 'Image must be 5 MB or smaller.',
},
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 lespace, sans réservation en ligne préalable.',
loading: 'Chargement des données de réservation...',
cancel: 'Annuler',
back: 'Retour',
continue: 'Continuer',
create: 'Créer la réservation',
creating: 'Création...',
edit: 'Modifier',
remove: 'Supprimer',
addAnotherDriver: 'Ajouter un conducteur',
customer: 'Client',
identity: 'Identité',
license: 'Permis',
rental: 'Location',
payment: 'Paiement et options',
review: 'Vérification',
stepOf: (current: number, total: number, label: string) => `Étape ${current} sur ${total} · ${label}`,
modes: { existing: 'Client existant', new: 'Nouveau client' },
searchCustomer: 'Rechercher un client existant',
selectCustomer: 'Sélectionner un client...',
firstName: 'Prénom',
lastName: 'Nom',
email: 'Email',
phone: 'Téléphone',
dateOfBirth: 'Date de naissance',
nationality: 'Nationalité',
fullAddress: 'Adresse complète',
identityDocumentNumber: 'N° CIN / passeport',
internationalLicenseNumber: 'N° de permis international',
loadedCustomerData: 'Données chargées depuis le client sélectionné. Vous pouvez les corriger avant de créer la réservation.',
driverLicenseNumber: 'Numéro du permis',
licenseCountry: 'Pays du permis',
licenseIssuedAt: 'Permis délivré le',
licenseExpiry: 'Expiration 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.',
licenseImageAlt: 'Aperçu du permis de conduire',
startDate: 'Date et heure de départ',
endDate: 'Date et heure de retour',
vehicle: 'Véhicule',
selectVehicle: 'Sélectionner un véhicule...',
pickup: 'Lieu de départ',
return: 'Lieu de retour',
noVehicles: 'Aucun véhicule disponible.',
noAvailableStatusVehicles: 'Des véhicules existent dans la flotte, mais aucun nest marqué Disponible.',
availabilityConflict: 'Le véhicule sélectionné nest pas disponible pour ces dates.',
deposit: 'Montant du dépôt (MAD)',
paymentMode: 'Mode de paiement',
notes: 'Notes',
notesPlaceholder: 'Notes optionnelles...',
additionalDriverInfo: 'Conducteur supplémentaire',
summaryEmpty: 'Non renseigné',
loadFailed: 'Échec du chargement des clients/véhicules.',
submitFailed: 'La création de la réservation a échoué. Le brouillon reste disponible pour correction ou nouvel essai.',
duplicateGuard: 'La création de réservation est déjà en cours.',
confirmReplaceCustomer: 'Remplacer les données chargées par celles du nouveau client sélectionné ?',
leaveWarning: 'Vous avez un brouillon de réservation non terminé.',
validation: {
required: 'Ce champ est obligatoire.',
email: 'Saisissez une adresse email valide.',
pastDate: 'La date doit être passée.',
futureIssue: 'La date de délivrance ne peut pas être future.',
expiryAfterIssue: 'La date dexpiration doit être après la date de délivrance.',
expiryFuture: 'La date dexpiration doit être future.',
endAfterStart: 'La date de fin doit être après la date de début.',
deposit: 'Le dépôt ne peut pas être négatif.',
imageType: 'Téléversez un fichier image pris en charge.',
imageSize: 'Limage doit faire 5 Mo ou moins.',
},
paymentModes: {
CASH: 'Espèces',
CARD: 'Carte',
BANK_TRANSFER: 'Virement',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
ar: {
title: 'حجز محلي',
subtitle: 'أنشئ حجزًا مباشرة من مساحة العمل بدون حجز إلكتروني مسبق.',
loading: 'جارٍ تحميل بيانات الحجز...',
cancel: 'إلغاء',
back: 'رجوع',
continue: 'متابعة',
create: 'إنشاء الحجز',
creating: 'جارٍ الإنشاء...',
edit: 'تعديل',
remove: 'إزالة',
addAnotherDriver: 'إضافة سائق آخر',
customer: 'العميل',
identity: 'الهوية',
license: 'رخصة القيادة',
rental: 'تفاصيل الكراء',
payment: 'الدفع والإضافات',
review: 'المراجعة',
stepOf: (current: number, total: number, label: string) => `الخطوة ${current} من ${total} · ${label}`,
modes: { existing: 'عميل موجود', new: 'عميل جديد' },
searchCustomer: 'ابحث عن عميل سابق',
selectCustomer: 'اختر عميلًا...',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
dateOfBirth: 'تاريخ الميلاد',
nationality: 'الجنسية',
fullAddress: 'العنوان الكامل',
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
internationalLicenseNumber: 'رقم الرخصة الدولية',
loadedCustomerData: 'تم تحميل البيانات من العميل المحدد. يمكنك تصحيحها قبل إنشاء الحجز.',
driverLicenseNumber: 'رقم رخصة القيادة',
licenseCountry: 'بلد الرخصة',
licenseIssuedAt: 'تاريخ إصدار الرخصة',
licenseExpiry: 'تاريخ انتهاء الرخصة',
licenseCategory: 'فئة الرخصة',
licenseImage: 'صورة رخصة القيادة',
licenseImageHint: 'ارفع صورة أو مسحًا لرخصة السائق الأساسي.',
licenseImageSelected: 'الملف المحدد',
licenseImageCurrent: 'الصورة الحالية',
noLicenseImage: 'لم يتم رفع صورة الرخصة بعد.',
licenseImageAlt: 'معاينة رخصة القيادة',
startDate: 'تاريخ ووقت البداية',
endDate: 'تاريخ ووقت النهاية',
vehicle: 'المركبة',
selectVehicle: 'اختر مركبة...',
pickup: 'موقع الاستلام',
return: 'موقع التسليم',
noVehicles: 'لا توجد مركبات متاحة.',
noAvailableStatusVehicles: 'توجد مركبات في الأسطول، لكن لا توجد مركبة بحالة متاحة.',
availabilityConflict: 'المركبة المحددة غير متاحة في هذه التواريخ.',
deposit: 'مبلغ العربون (MAD)',
paymentMode: 'طريقة الدفع',
notes: 'ملاحظات',
notesPlaceholder: 'ملاحظات اختيارية...',
additionalDriverInfo: 'السائق الإضافي',
summaryEmpty: 'غير متوفر',
loadFailed: 'فشل تحميل العملاء/المركبات.',
submitFailed: 'فشل إنشاء الحجز. لا يزال المسود متاحًا للتصحيح أو إعادة المحاولة.',
duplicateGuard: 'إنشاء الحجز قيد التنفيذ بالفعل.',
confirmReplaceCustomer: 'هل تريد استبدال بيانات العميل المحملة ببيانات العميل المحدد الجديد؟',
leaveWarning: 'لديك مسودة حجز غير مكتملة.',
validation: {
required: 'هذا الحقل مطلوب.',
email: 'أدخل بريدًا إلكترونيًا صالحًا.',
pastDate: 'يجب أن يكون التاريخ في الماضي.',
futureIssue: 'لا يمكن أن يكون تاريخ الإصدار في المستقبل.',
expiryAfterIssue: 'يجب أن يكون تاريخ الانتهاء بعد تاريخ الإصدار.',
expiryFuture: 'يجب أن يكون تاريخ الانتهاء في المستقبل.',
endAfterStart: 'يجب أن يكون تاريخ النهاية بعد تاريخ البداية.',
deposit: 'لا يمكن أن يكون العربون سالبًا.',
imageType: 'ارفع ملف صورة مدعومًا.',
imageSize: 'يجب ألا تتجاوز الصورة 5 ميغابايت.',
},
paymentModes: {
CASH: 'نقدا',
CARD: 'بطاقة',
BANK_TRANSFER: 'تحويل بنكي',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
}[language]
}
export type ReservationWizardCopy = ReturnType<typeof getReservationWizardCopy>
export const wizardSteps: Array<{ id: WizardStepId; labelKey: keyof ReservationWizardCopy }> = [
{ id: 'customer', labelKey: 'customer' },
{ id: 'identity', labelKey: 'identity' },
{ id: 'license', labelKey: 'license' },
{ id: 'rental', labelKey: 'rental' },
{ id: 'payment', labelKey: 'payment' },
{ id: 'review', labelKey: 'review' },
]
@@ -0,0 +1,163 @@
import { emptyBilingual } from '@/components/ui/BilingualInput'
import type { AdditionalDriverDraft, Customer, ReservationDraft } from './reservationWizard.types'
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 : ''
}
export function createAdditionalDriver(): AdditionalDriverDraft {
return {
id: crypto.randomUUID(),
firstName: emptyBilingual(),
lastName: emptyBilingual(),
email: '',
phone: '',
driverLicense: '',
licenseExpiry: '',
licenseIssuedAt: '',
dateOfBirth: '',
nationality: emptyBilingual(),
}
}
export function createInitialDraft(): ReservationDraft {
return {
customer: {
mode: 'existing',
selectedCustomerId: null,
createdCustomerId: null,
firstName: emptyBilingual(),
lastName: emptyBilingual(),
email: '',
phone: '',
hydratedFromCustomerId: null,
},
identity: {
dateOfBirth: '',
nationality: '',
fullAddress: '',
identityDocumentNumber: '',
internationalLicenseNumber: '',
},
license: {
number: '',
country: '',
issuedAt: '',
expiry: '',
category: '',
existingImageUrl: null,
newImageFile: null,
},
rental: {
vehicleId: '',
startDate: '',
endDate: '',
pickupLocation: '',
returnLocation: '',
},
payment: {
depositAmount: '0',
paymentMode: 'CASH',
notes: '',
},
additionalDrivers: [],
}
}
export type ReservationDraftAction =
| { type: 'setCustomerMode'; mode: 'existing' | 'new' }
| { type: 'selectExistingCustomer'; customer: Customer }
| { type: 'setCreatedCustomerId'; customerId: string }
| { type: 'updateCustomer'; field: keyof ReservationDraft['customer']; value: any }
| { type: 'updateIdentity'; field: keyof ReservationDraft['identity']; value: string }
| { type: 'updateLicense'; field: keyof ReservationDraft['license']; value: any }
| { type: 'updateRental'; field: keyof ReservationDraft['rental']; value: string }
| { type: 'updatePayment'; field: keyof ReservationDraft['payment']; value: string }
| { type: 'addAdditionalDriver' }
| { type: 'removeAdditionalDriver'; id: string }
| { type: 'updateAdditionalDriver'; id: string; field: keyof AdditionalDriverDraft; value: any }
| { type: 'reset' }
export function reservationDraftReducer(draft: ReservationDraft, action: ReservationDraftAction): ReservationDraft {
switch (action.type) {
case 'setCustomerMode':
if (action.mode === draft.customer.mode) return draft
if (action.mode === 'new') {
return {
...draft,
customer: {
...createInitialDraft().customer,
mode: 'new',
createdCustomerId: draft.customer.createdCustomerId,
},
identity: createInitialDraft().identity,
license: createInitialDraft().license,
}
}
return {
...draft,
customer: createInitialDraft().customer,
identity: createInitialDraft().identity,
license: createInitialDraft().license,
}
case 'selectExistingCustomer':
return {
...draft,
customer: {
mode: 'existing',
selectedCustomerId: action.customer.id,
createdCustomerId: null,
firstName: { fr: action.customer.firstName ?? '', ar: action.customer.firstNameAr ?? '' },
lastName: { fr: action.customer.lastName ?? '', ar: action.customer.lastNameAr ?? '' },
email: action.customer.email ?? '',
phone: action.customer.phone ?? '',
hydratedFromCustomerId: action.customer.id,
},
identity: {
dateOfBirth: action.customer.dateOfBirth ? action.customer.dateOfBirth.slice(0, 10) : '',
nationality: action.customer.nationality ?? '',
fullAddress: readCustomerAddressValue(action.customer, 'fullAddress'),
identityDocumentNumber: readCustomerAddressValue(action.customer, 'identityDocumentNumber'),
internationalLicenseNumber: readCustomerAddressValue(action.customer, 'internationalLicenseNumber'),
},
license: {
number: action.customer.driverLicense ?? action.customer.licenseNumber ?? '',
country: action.customer.licenseCountry ?? '',
issuedAt: action.customer.licenseIssuedAt ? action.customer.licenseIssuedAt.slice(0, 10) : '',
expiry: action.customer.licenseExpiry ? action.customer.licenseExpiry.slice(0, 10) : '',
category: action.customer.licenseCategory ?? '',
existingImageUrl: action.customer.licenseImageUrl ?? null,
newImageFile: null,
},
}
case 'setCreatedCustomerId':
return { ...draft, customer: { ...draft.customer, createdCustomerId: action.customerId } }
case 'updateCustomer':
return { ...draft, customer: { ...draft.customer, [action.field]: action.value } }
case 'updateIdentity':
return { ...draft, identity: { ...draft.identity, [action.field]: action.value } }
case 'updateLicense':
return { ...draft, license: { ...draft.license, [action.field]: action.value } }
case 'updateRental':
return { ...draft, rental: { ...draft.rental, [action.field]: action.value } }
case 'updatePayment':
return { ...draft, payment: { ...draft.payment, [action.field]: action.value } }
case 'addAdditionalDriver':
return { ...draft, additionalDrivers: [...draft.additionalDrivers, createAdditionalDriver()] }
case 'removeAdditionalDriver':
return { ...draft, additionalDrivers: draft.additionalDrivers.filter((driver) => driver.id !== action.id) }
case 'updateAdditionalDriver':
return {
...draft,
additionalDrivers: draft.additionalDrivers.map((driver) =>
driver.id === action.id ? { ...driver, [action.field]: action.value } : driver,
),
}
case 'reset':
return createInitialDraft()
}
}
@@ -0,0 +1,138 @@
import { z } from 'zod'
import { bilingualPrimary } from '@/components/ui/BilingualInput'
import type { FieldErrors, ReservationDraft } from './reservationWizard.types'
import type { ReservationWizardCopy } from './reservationWizard.copy'
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/heic', 'image/heif'])
function nonEmpty(message: string) {
return z.string().trim().min(1, message)
}
function emailSchema(copy: ReservationWizardCopy) {
return nonEmpty(copy.validation.required).email(copy.validation.email)
}
function dateInPast(value: string) {
if (!value) return false
const date = new Date(value)
return Number.isFinite(date.getTime()) && date < new Date()
}
function addIssue(ctx: z.RefinementCtx, path: string[], message: string) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path, message })
}
export function validateCustomerStep(draft: ReservationDraft, copy: ReservationWizardCopy): FieldErrors {
const schema = z.object({
mode: z.enum(['existing', 'new']),
selectedCustomerId: z.string().nullable(),
firstName: z.any(),
lastName: z.any(),
email: z.string(),
phone: z.string(),
}).superRefine((customer, ctx) => {
if (customer.mode === 'existing') {
if (!customer.selectedCustomerId) addIssue(ctx, ['selectedCustomerId'], copy.validation.required)
return
}
if (!bilingualPrimary(customer.firstName).trim()) addIssue(ctx, ['firstName'], copy.validation.required)
if (!bilingualPrimary(customer.lastName).trim()) addIssue(ctx, ['lastName'], copy.validation.required)
if (!emailSchema(copy).safeParse(customer.email).success) addIssue(ctx, ['email'], customer.email.trim() ? copy.validation.email : copy.validation.required)
if (!customer.phone.trim()) addIssue(ctx, ['phone'], copy.validation.required)
})
return zodErrors(schema.safeParse(draft.customer), 'customer')
}
export function validateIdentityStep(draft: ReservationDraft, copy: ReservationWizardCopy): FieldErrors {
const schema = z.object({
dateOfBirth: nonEmpty(copy.validation.required),
nationality: nonEmpty(copy.validation.required),
fullAddress: nonEmpty(copy.validation.required),
identityDocumentNumber: nonEmpty(copy.validation.required),
internationalLicenseNumber: z.string(),
}).superRefine((identity, ctx) => {
if (identity.dateOfBirth && !dateInPast(identity.dateOfBirth)) addIssue(ctx, ['dateOfBirth'], copy.validation.pastDate)
})
return zodErrors(schema.safeParse(draft.identity), 'identity')
}
export function validateLicenseStep(draft: ReservationDraft, copy: ReservationWizardCopy): FieldErrors {
const schema = z.object({
number: nonEmpty(copy.validation.required),
country: nonEmpty(copy.validation.required),
issuedAt: nonEmpty(copy.validation.required),
expiry: nonEmpty(copy.validation.required),
category: nonEmpty(copy.validation.required),
newImageFile: z.any().nullable(),
}).superRefine((license, ctx) => {
const issuedAt = new Date(license.issuedAt)
const expiry = new Date(license.expiry)
const now = new Date()
if (license.issuedAt && issuedAt > now) addIssue(ctx, ['issuedAt'], copy.validation.futureIssue)
if (license.issuedAt && license.expiry && expiry <= issuedAt) addIssue(ctx, ['expiry'], copy.validation.expiryAfterIssue)
if (license.expiry && expiry <= now) addIssue(ctx, ['expiry'], copy.validation.expiryFuture)
if (typeof File !== 'undefined' && license.newImageFile instanceof File) {
if (!IMAGE_TYPES.has(license.newImageFile.type)) addIssue(ctx, ['newImageFile'], copy.validation.imageType)
if (license.newImageFile.size > MAX_IMAGE_BYTES) addIssue(ctx, ['newImageFile'], copy.validation.imageSize)
}
})
return zodErrors(schema.safeParse(draft.license), 'license')
}
export function validateRentalStep(draft: ReservationDraft, copy: ReservationWizardCopy): FieldErrors {
const schema = z.object({
vehicleId: nonEmpty(copy.validation.required),
startDate: nonEmpty(copy.validation.required),
endDate: nonEmpty(copy.validation.required),
pickupLocation: z.string(),
returnLocation: z.string(),
}).superRefine((rental, ctx) => {
if (rental.startDate && rental.endDate && new Date(rental.endDate) <= new Date(rental.startDate)) {
addIssue(ctx, ['endDate'], copy.validation.endAfterStart)
}
})
return zodErrors(schema.safeParse(draft.rental), 'rental')
}
export function validatePaymentExtrasStep(draft: ReservationDraft, copy: ReservationWizardCopy): FieldErrors {
const errors: FieldErrors = {}
const deposit = Number(draft.payment.depositAmount)
if (!Number.isFinite(deposit) || deposit < 0) errors['payment.depositAmount'] = copy.validation.deposit
if (!draft.payment.paymentMode.trim()) errors['payment.paymentMode'] = copy.validation.required
draft.additionalDrivers.forEach((driver, index) => {
if (!bilingualPrimary(driver.firstName).trim()) errors[`additionalDrivers.${index}.firstName`] = copy.validation.required
if (!bilingualPrimary(driver.lastName).trim()) errors[`additionalDrivers.${index}.lastName`] = copy.validation.required
if (!driver.driverLicense.trim()) errors[`additionalDrivers.${index}.driverLicense`] = copy.validation.required
if (driver.licenseIssuedAt && new Date(driver.licenseIssuedAt) > new Date()) {
errors[`additionalDrivers.${index}.licenseIssuedAt`] = copy.validation.futureIssue
}
if (driver.licenseIssuedAt && driver.licenseExpiry && new Date(driver.licenseExpiry) <= new Date(driver.licenseIssuedAt)) {
errors[`additionalDrivers.${index}.licenseExpiry`] = copy.validation.expiryAfterIssue
}
if (driver.dateOfBirth && !dateInPast(driver.dateOfBirth)) {
errors[`additionalDrivers.${index}.dateOfBirth`] = copy.validation.pastDate
}
})
return errors
}
export function validateCompleteReservation(draft: ReservationDraft, copy: ReservationWizardCopy): FieldErrors {
return {
...validateCustomerStep(draft, copy),
...validateIdentityStep(draft, copy),
...validateLicenseStep(draft, copy),
...validateRentalStep(draft, copy),
...validatePaymentExtrasStep(draft, copy),
}
}
function zodErrors(result: z.SafeParseReturnType<unknown, unknown>, prefix: string): FieldErrors {
if (result.success) return {}
return result.error.issues.reduce<FieldErrors>((errors, issue) => {
const path = issue.path.length > 0 ? `${prefix}.${issue.path.join('.')}` : prefix
errors[path] = issue.message
return errors
}, {})
}
@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from 'vitest'
import { apiFetch } from '@/lib/api'
import { submitReservationDraft } from './reservationWizard.submit'
import type { ReservationDraft } from './reservationWizard.types'
vi.mock('@/lib/api', () => ({
apiFetch: vi.fn(),
}))
function draft(): ReservationDraft {
return {
customer: {
mode: 'new',
selectedCustomerId: null,
createdCustomerId: null,
firstName: { fr: 'Sara', ar: '' },
lastName: { fr: 'Alaoui', ar: '' },
email: 'sara@example.com',
phone: '+212600000000',
hydratedFromCustomerId: null,
},
identity: {
dateOfBirth: '1990-01-01',
nationality: 'Moroccan',
fullAddress: '12 Main Street',
identityDocumentNumber: 'AB123',
internationalLicenseNumber: '',
},
license: {
number: 'DL-123',
country: 'MA',
issuedAt: '2020-01-01',
expiry: '2099-01-01',
category: 'B',
existingImageUrl: null,
newImageFile: null,
},
rental: {
vehicleId: 'vehicle_1',
startDate: '2026-07-01T10:00',
endDate: '2026-07-03T10:00',
pickupLocation: '',
returnLocation: '',
},
payment: {
depositAmount: '0',
paymentMode: 'CASH',
notes: '',
},
additionalDrivers: [],
}
}
describe('submitReservationDraft', () => {
it('reports the created customer id before a later reservation failure', async () => {
const api = vi.mocked(apiFetch)
api
.mockResolvedValueOnce({ available: true })
.mockResolvedValueOnce({ id: 'customer_1' })
.mockRejectedValueOnce(new Error('reservation failed'))
const resolvedCustomerIds: string[] = []
await expect(submitReservationDraft(draft(), {
onCustomerIdResolved: (customerId) => resolvedCustomerIds.push(customerId),
})).rejects.toThrow('reservation failed')
expect(resolvedCustomerIds).toEqual(['customer_1'])
expect(api).toHaveBeenCalledTimes(3)
})
it('uses a previously created customer on retry', async () => {
const api = vi.mocked(apiFetch)
api
.mockResolvedValueOnce({ available: true })
.mockResolvedValueOnce({ id: 'reservation_1' })
const retryDraft = draft()
retryDraft.customer.createdCustomerId = 'customer_1'
await submitReservationDraft(retryDraft)
expect(api).toHaveBeenNthCalledWith(2, '/customers/customer_1', expect.objectContaining({ method: 'PATCH' }))
})
})
@@ -0,0 +1,139 @@
import { apiFetch } from '@/lib/api'
import { bilingualPrimary } from '@/components/ui/BilingualInput'
import type { CreatedReservation, Customer, ReservationDraft } from './reservationWizard.types'
type AvailabilityResult = { available: boolean }
function isoFromLocal(value: string) {
return new Date(value).toISOString()
}
export async function checkVehicleAvailability(draft: ReservationDraft) {
const { vehicleId, startDate, endDate } = draft.rental
const params = new URLSearchParams({
startDate: isoFromLocal(startDate),
endDate: isoFromLocal(endDate),
})
return apiFetch<AvailabilityResult>(`/vehicles/${vehicleId}/availability?${params.toString()}`)
}
export async function uploadLicenseImage(customerId: string, file: File) {
const formData = new FormData()
formData.append('file', file)
return apiFetch<Customer>(`/customers/${customerId}/license-image`, {
method: 'POST',
body: formData,
})
}
function customerPayload(draft: ReservationDraft) {
return {
firstName: bilingualPrimary(draft.customer.firstName).trim(),
firstNameAr: draft.customer.firstName.ar.trim() || undefined,
lastName: bilingualPrimary(draft.customer.lastName).trim(),
lastNameAr: draft.customer.lastName.ar.trim() || undefined,
email: draft.customer.email.trim(),
phone: draft.customer.phone.trim(),
driverLicense: draft.license.number.trim(),
dateOfBirth: isoFromLocal(draft.identity.dateOfBirth),
nationality: draft.identity.nationality.trim(),
address: {
fullAddress: draft.identity.fullAddress.trim(),
identityDocumentNumber: draft.identity.identityDocumentNumber.trim(),
internationalLicenseNumber: draft.identity.internationalLicenseNumber.trim() || undefined,
},
licenseExpiry: isoFromLocal(draft.license.expiry),
licenseIssuedAt: isoFromLocal(draft.license.issuedAt),
licenseCountry: draft.license.country.trim(),
licenseNumber: draft.license.number.trim(),
licenseCategory: draft.license.category.trim(),
}
}
function customerPatchPayload(draft: ReservationDraft) {
return {
driverLicense: draft.license.number.trim(),
dateOfBirth: isoFromLocal(draft.identity.dateOfBirth),
nationality: draft.identity.nationality.trim(),
address: {
fullAddress: draft.identity.fullAddress.trim(),
identityDocumentNumber: draft.identity.identityDocumentNumber.trim(),
internationalLicenseNumber: draft.identity.internationalLicenseNumber.trim() || undefined,
},
licenseExpiry: isoFromLocal(draft.license.expiry),
licenseIssuedAt: isoFromLocal(draft.license.issuedAt),
licenseCountry: draft.license.country.trim(),
licenseNumber: draft.license.number.trim(),
licenseCategory: draft.license.category.trim(),
}
}
function reservationPayload(draft: ReservationDraft, customerId: string) {
return {
customerId,
vehicleId: draft.rental.vehicleId,
startDate: isoFromLocal(draft.rental.startDate),
endDate: isoFromLocal(draft.rental.endDate),
pickupLocation: draft.rental.pickupLocation.trim() || undefined,
returnLocation: draft.rental.returnLocation.trim() || undefined,
depositAmount: Number.isFinite(Number(draft.payment.depositAmount)) ? Math.max(0, Math.round(Number(draft.payment.depositAmount))) : 0,
paymentMode: draft.payment.paymentMode,
additionalDrivers: draft.additionalDrivers.map((driver) => ({
firstName: bilingualPrimary(driver.firstName).trim(),
firstNameAr: driver.firstName.ar.trim() || undefined,
lastName: bilingualPrimary(driver.lastName).trim(),
lastNameAr: driver.lastName.ar.trim() || undefined,
email: driver.email.trim() || undefined,
phone: driver.phone.trim() || undefined,
driverLicense: driver.driverLicense.trim(),
licenseExpiry: driver.licenseExpiry ? isoFromLocal(driver.licenseExpiry) : undefined,
licenseIssuedAt: driver.licenseIssuedAt ? isoFromLocal(driver.licenseIssuedAt) : undefined,
dateOfBirth: driver.dateOfBirth ? isoFromLocal(driver.dateOfBirth) : undefined,
nationality: bilingualPrimary(driver.nationality).trim() || undefined,
nationalityAr: driver.nationality.ar.trim() || undefined,
})),
notes: draft.payment.notes.trim() || undefined,
}
}
export async function submitReservationDraft(
draft: ReservationDraft,
options: { onCustomerIdResolved?: (customerId: string) => void } = {},
) {
const availability = await checkVehicleAvailability(draft)
if (!availability.available) {
const error = new Error('vehicle_unavailable') as Error & { code?: string }
error.code = 'vehicle_unavailable'
throw error
}
let customerId = draft.customer.mode === 'new'
? draft.customer.createdCustomerId
: draft.customer.selectedCustomerId
if (!customerId) {
const createdCustomer = await apiFetch<Customer>('/customers', {
method: 'POST',
body: JSON.stringify(customerPayload(draft)),
})
customerId = createdCustomer.id
options.onCustomerIdResolved?.(customerId)
} else {
await apiFetch<Customer>(`/customers/${customerId}`, {
method: 'PATCH',
body: JSON.stringify(customerPatchPayload(draft)),
})
options.onCustomerIdResolved?.(customerId)
}
if (draft.license.newImageFile) {
await uploadLicenseImage(customerId, draft.license.newImageFile)
}
const reservation = await apiFetch<CreatedReservation>('/reservations', {
method: 'POST',
body: JSON.stringify(reservationPayload(draft, customerId)),
})
return { reservation, customerId }
}
@@ -0,0 +1,167 @@
import { describe, expect, it } from 'vitest'
import { emptyBilingual } from '@/components/ui/BilingualInput'
import { getReservationWizardCopy } from './reservationWizard.copy'
import { createInitialDraft, reservationDraftReducer } from './reservationWizard.reducer'
import {
validateCustomerStep,
validateIdentityStep,
validateLicenseStep,
validatePaymentExtrasStep,
validateRentalStep,
} from './reservationWizard.schemas'
import type { Customer } from './reservationWizard.types'
const copy = getReservationWizardCopy('en')
function validDraft() {
return {
...createInitialDraft(),
customer: {
...createInitialDraft().customer,
mode: 'new' as const,
firstName: { fr: 'Sara', ar: '' },
lastName: { fr: 'Alaoui', ar: '' },
email: 'sara@example.com',
phone: '+212600000000',
},
identity: {
dateOfBirth: '1990-01-01',
nationality: 'Moroccan',
fullAddress: '12 Main Street',
identityDocumentNumber: 'AB123',
internationalLicenseNumber: '',
},
license: {
number: 'DL-123',
country: 'MA',
issuedAt: '2020-01-01',
expiry: '2099-01-01',
category: 'B',
existingImageUrl: null,
newImageFile: null,
},
rental: {
vehicleId: 'vehicle_1',
startDate: '2026-07-01T10:00',
endDate: '2026-07-03T10:00',
pickupLocation: '',
returnLocation: '',
},
payment: {
depositAmount: '0',
paymentMode: 'CASH',
notes: '',
},
additionalDrivers: [],
}
}
describe('reservation wizard reducer', () => {
it('hydrates existing customer identity and license data', () => {
const customer: Customer = {
id: 'customer_1',
firstName: 'Sara',
firstNameAr: 'سارة',
lastName: 'Alaoui',
lastNameAr: 'العلوي',
email: 'sara@example.com',
phone: '+212600000000',
driverLicense: 'DL-1',
dateOfBirth: '1990-01-01T00:00:00.000Z',
nationality: 'Moroccan',
address: { fullAddress: 'Rabat', identityDocumentNumber: 'AB123', internationalLicenseNumber: 'INT-1' },
licenseIssuedAt: '2020-01-01T00:00:00.000Z',
licenseExpiry: '2099-01-01T00:00:00.000Z',
licenseCountry: 'MA',
licenseCategory: 'B',
licenseImageUrl: '/license.jpg',
}
const draft = reservationDraftReducer(createInitialDraft(), { type: 'selectExistingCustomer', customer })
expect(draft.customer.selectedCustomerId).toBe('customer_1')
expect(draft.customer.firstName).toEqual({ fr: 'Sara', ar: 'سارة' })
expect(draft.identity.fullAddress).toBe('Rabat')
expect(draft.license.number).toBe('DL-1')
expect(draft.license.existingImageUrl).toBe('/license.jpg')
})
it('adds and removes additional drivers as an array', () => {
const added = reservationDraftReducer(createInitialDraft(), { type: 'addAdditionalDriver' })
expect(added.additionalDrivers).toHaveLength(1)
const removed = reservationDraftReducer(added, { type: 'removeAdditionalDriver', id: added.additionalDrivers[0].id })
expect(removed.additionalDrivers).toEqual([])
})
it('clears hydrated customer identity and license data when switching modes', () => {
const customer: Customer = {
id: 'customer_1',
firstName: 'Sara',
lastName: 'Alaoui',
email: 'sara@example.com',
driverLicense: 'DL-1',
dateOfBirth: '1990-01-01T00:00:00.000Z',
nationality: 'Moroccan',
address: { fullAddress: 'Rabat', identityDocumentNumber: 'AB123' },
licenseIssuedAt: '2020-01-01T00:00:00.000Z',
licenseExpiry: '2099-01-01T00:00:00.000Z',
licenseCountry: 'MA',
licenseCategory: 'B',
}
const hydrated = reservationDraftReducer(createInitialDraft(), { type: 'selectExistingCustomer', customer })
const switched = reservationDraftReducer(hydrated, { type: 'setCustomerMode', mode: 'new' })
expect(switched.customer.mode).toBe('new')
expect(switched.customer.selectedCustomerId).toBeNull()
expect(switched.customer.email).toBe('')
expect(switched.identity.fullAddress).toBe('')
expect(switched.license.number).toBe('')
})
})
describe('reservation wizard validation', () => {
it('validates new customer required fields', () => {
const draft = createInitialDraft()
const errors = validateCustomerStep({ ...draft, customer: { ...draft.customer, mode: 'new', firstName: emptyBilingual() } }, copy)
expect(errors['customer.firstName']).toBe(copy.validation.required)
expect(errors['customer.email']).toBe(copy.validation.required)
})
it('validates date relationships in rental and license steps', () => {
const draft = validDraft()
expect(validateRentalStep({ ...draft, rental: { ...draft.rental, endDate: '2026-06-30T10:00' } }, copy)['rental.endDate']).toBe(copy.validation.endAfterStart)
expect(validateLicenseStep({ ...draft, license: { ...draft.license, expiry: '2019-01-01' } }, copy)['license.expiry']).toBe(copy.validation.expiryFuture)
})
it('validates optional additional driver only when present', () => {
const draft = {
...validDraft(),
additionalDrivers: [{
id: 'driver_1',
firstName: emptyBilingual(),
lastName: { fr: 'Driver', ar: '' },
email: '',
phone: '',
driverLicense: '',
licenseExpiry: '',
licenseIssuedAt: '',
dateOfBirth: '',
nationality: emptyBilingual(),
}],
}
const errors = validatePaymentExtrasStep(draft, copy)
expect(errors['additionalDrivers.0.firstName']).toBe(copy.validation.required)
expect(errors['additionalDrivers.0.driverLicense']).toBe(copy.validation.required)
})
it('accepts a complete valid draft', () => {
expect(validateCustomerStep(validDraft(), copy)).toEqual({})
expect(validateIdentityStep(validDraft(), copy)).toEqual({})
expect(validateLicenseStep(validDraft(), copy)).toEqual({})
expect(validateRentalStep(validDraft(), copy)).toEqual({})
expect(validatePaymentExtrasStep(validDraft(), copy)).toEqual({})
})
})
@@ -0,0 +1,91 @@
import type { BilingualField } from '@/components/ui/BilingualInput'
export type Customer = {
id: string
firstName: string
firstNameAr?: string | null
lastName: string
lastNameAr?: string | null
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
}
export type Vehicle = {
id: string
make: string
model: string
licensePlate: string
status: string
}
export type AdditionalDriverDraft = {
id: string
firstName: BilingualField
lastName: BilingualField
email: string
phone: string
driverLicense: string
licenseExpiry: string
licenseIssuedAt: string
dateOfBirth: string
nationality: BilingualField
}
export type ReservationDraft = {
customer: {
mode: 'existing' | 'new'
selectedCustomerId: string | null
createdCustomerId: string | null
firstName: BilingualField
lastName: BilingualField
email: string
phone: string
hydratedFromCustomerId: string | null
}
identity: {
dateOfBirth: string
nationality: string
fullAddress: string
identityDocumentNumber: string
internationalLicenseNumber: string
}
license: {
number: string
country: string
issuedAt: string
expiry: string
category: string
existingImageUrl: string | null
newImageFile: File | null
}
rental: {
vehicleId: string
startDate: string
endDate: string
pickupLocation: string
returnLocation: string
}
payment: {
depositAmount: string
paymentMode: string
notes: string
}
additionalDrivers: AdditionalDriverDraft[]
}
export type WizardStepId = 'customer' | 'identity' | 'license' | 'rental' | 'payment' | 'review'
export type FieldErrors = Record<string, string>
export type CreatedReservation = { id: string }
+8
View File
@@ -6,6 +6,14 @@ MARKER_FILE="$STATE_DIR/dev-bootstrap-complete"
mkdir -p "$STATE_DIR" mkdir -p "$STATE_DIR"
EXPECTED_TURBO_VERSION="$(node -e "const lock=require('/app/package-lock.json'); process.stdout.write(lock.packages?.['node_modules/turbo']?.version || '')")"
INSTALLED_TURBO_VERSION="$(node -e "try { process.stdout.write(require('/app/node_modules/turbo/package.json').version || '') } catch (_) {}")"
if [ -n "$EXPECTED_TURBO_VERSION" ] && [ "$INSTALLED_TURBO_VERSION" != "$EXPECTED_TURBO_VERSION" ]; then
echo "[migrate] Refreshing node_modules volume for turbo $EXPECTED_TURBO_VERSION"
npm ci --no-fund --no-audit
fi
echo "[migrate] Generating database client" echo "[migrate] Generating database client"
npm run db:generate npm run db:generate
+634
View File
@@ -0,0 +1,634 @@
# Progressive Reservation Creation: Action Plan
## 1. Objective
Replace the single long reservation form at:
`/dashboard/reservations/new`
with a progressive wizard that:
* Shows one focused section at a time.
* Validates only the current section before continuing.
* Preserves entered information when moving backward or forward.
* Provides a final review before creating the reservation.
* Works correctly in English, French, and Arabic.
* Supports desktop, mobile, light mode, dark mode, LTR, and RTL.
* Prevents duplicate submissions and handles partial API failures safely.
The reservation should not be created until the user confirms the final review step.
---
## 2. Problems in the Current Implementation
The existing page combines all responsibilities inside one component:
* Customer search and selection.
* New customer creation.
* Customer identity information.
* Driver license information.
* License image upload.
* Vehicle selection.
* Rental dates and locations.
* Deposit and payment method.
* Additional driver information.
* Notes.
* Validation.
* Customer creation and updates.
* Reservation submission.
* Translated copy.
This creates several practical problems:
1. The user sees too much information at once.
2. Validation returns one generic error instead of identifying the exact field.
3. Adding a customer is confusing because required identity and license fields appear outside the customer creation panel.
4. Navigation away from the page loses the entire form.
5. Customer updates, license uploads, and reservation creation can partially succeed.
6. The component will become increasingly difficult to maintain.
7. Vehicle availability is currently based only on `status === 'AVAILABLE'`, not the selected rental dates. That does not reliably prevent conflicting reservations.
---
## 3. Proposed Wizard Structure
Use six fixed steps. Six is the upper reasonable limit here. More steps would create needless clicking; fewer would leave several sections overloaded.
### Step 1: Customer
Purpose: Identify who is making the reservation.
Fields and actions:
* Choose `Existing customer` or `New customer`.
* Search existing customers.
* Select an existing customer.
* For a new customer:
* First name.
* Arabic first name.
* Last name.
* Arabic last name.
* Email.
* Phone.
Completion requirements:
* Existing customer mode: a customer must be selected.
* New customer mode: first name, last name, email, and phone must be valid.
Important behavior:
* Selecting an existing customer preloads known identity and license data.
* Creating a new customer should not call the API at this step.
* Switching between existing and new customer modes must not silently mix the two records.
* Changing the selected customer after editing loaded data should display a confirmation before replacing those edits.
### Step 2: Identity
Purpose: Verify the renters personal information.
Fields:
* Date of birth.
* Nationality.
* CIN or passport number.
* Full address.
* International permit number, optional.
Completion requirements:
* Date of birth is present and in the past.
* Nationality is present.
* Identity document number is present.
* Full address is present.
The section should display whether values were loaded from an existing customer and allow the user to correct them.
### Step 3: Driver License
Purpose: Verify that the primary renter is legally eligible to drive.
Fields:
* Driver license number.
* License country.
* License issue date.
* License expiry date.
* License category.
* License image, optional unless business rules later require it.
Completion requirements:
* Required license fields are complete.
* Issue date is not in the future.
* Expiry date is after the issue date.
* Expiry date has not already passed.
* Uploaded file is a supported image type and within the configured size limit.
The current image and newly selected preview should be clearly distinguished.
### Step 4: Rental Details
Purpose: Define what is being rented and when.
Recommended field order:
1. Start date and time.
2. End date and time.
3. Vehicle.
4. Pickup location.
5. Return location.
Completion requirements:
* Start date is valid.
* End date is later than the start date.
* A vehicle is selected.
* The vehicle remains available for the chosen period.
Important correction:
The current implementation lists vehicles using only their general `AVAILABLE` status. The final implementation should request or revalidate date-based availability. A vehicle can be marked available now while already reserved for the requested dates.
If the API does not currently support date-based availability, this must be recorded as a backend dependency rather than pretending the status filter is sufficient.
### Step 5: Payment and Extras
Purpose: Collect optional and commercial details.
Main fields:
* Deposit amount.
* Payment method.
* Notes.
Optional additional driver:
* Display a compact `Add another driver` control.
* Opening it reveals an additional-driver panel or nested form.
* Allow removal before submission.
* Structure the state as an array even if the current interface initially supports only one driver.
Additional-driver fields:
* First and last name.
* Arabic first and last name.
* Email, optional.
* Phone, optional.
* Date of birth, optional.
* Nationality, optional.
* License number.
* License issue date, optional.
* License expiry date, optional.
Completion requirements:
* Deposit cannot be negative.
* A payment method must be selected.
* If an additional driver is enabled, name and license number are required.
* Any entered additional-driver dates must be logically valid.
### Step 6: Review and Confirm
Purpose: Prevent avoidable mistakes before committing data.
Display concise summaries for:
* Customer.
* Identity.
* Primary driver license.
* Vehicle.
* Rental period.
* Pickup and return locations.
* Payment.
* Additional drivers.
* Notes.
Each summary section should include an `Edit` action that returns directly to the relevant step.
Final actions:
* Back.
* Cancel.
* Create reservation.
The final button must display a loading state and remain disabled after the first click until the request succeeds or fails.
---
## 4. Navigation and Progress Behavior
### Desktop
Use a horizontal stepper containing:
* Step number.
* Short step label.
* Completed state.
* Current state.
* Future state.
### Mobile
Use a compact header:
`Step 3 of 6 · Driver license`
Include a progress bar rather than compressing all six labels into unusable decorative confetti.
### Navigation rules
* `Continue` validates the current step.
* `Back` never discards information.
* Completed steps may be revisited.
* Future incomplete steps cannot be opened directly.
* The final review step is available only after all previous steps are valid.
* Pressing Enter must not accidentally submit the final reservation from an earlier step.
* The footer containing Back and Continue should remain visible on long steps where practical.
---
## 5. Validation Strategy
Replace the current generic `Please fill all required fields` behavior with step-specific validation.
Use Zod, which is already installed, to create separate schemas:
* `customerStepSchema`
* `identityStepSchema`
* `licenseStepSchema`
* `rentalStepSchema`
* `paymentExtrasStepSchema`
* `completeReservationSchema`
Validation behavior:
* Validate on Continue.
* Show the message beside the invalid field.
* Focus the first invalid field.
* Clear an error once the value becomes valid.
* Run complete validation again before final submission.
* Translate validation messages into English, French, and Arabic.
Do not depend on disabled buttons alone. A disabled button gives the user no useful explanation and is a remarkably efficient way to make software feel broken.
---
## 6. State Management Refactor
Replace the large collection of independent `useState` calls with one structured draft state managed by `useReducer`.
Suggested shape:
```ts
type ReservationDraft = {
customer: {
mode: 'existing' | 'new'
selectedCustomerId: string | null
firstName: BilingualField
lastName: BilingualField
email: string
phone: string
}
identity: {
dateOfBirth: string
nationality: string
fullAddress: string
identityDocumentNumber: string
internationalLicenseNumber: string
}
license: {
number: string
country: string
issuedAt: string
expiry: string
category: string
existingImageUrl: string | null
newImageFile: File | null
}
rental: {
vehicleId: string
startDate: string
endDate: string
pickupLocation: string
returnLocation: string
}
payment: {
depositAmount: string
paymentMode: string
notes: string
}
additionalDrivers: AdditionalDriverDraft[]
}
```
The reducer should support explicit actions such as:
* Select existing customer.
* Start new customer.
* Update step field.
* Add additional driver.
* Remove additional driver.
* Hydrate customer information.
* Clear customer information.
* Store a newly created customer ID after partial submission.
* Reset the entire draft.
This makes state transitions testable and prevents unrelated fields from being scattered throughout the page.
---
## 7. Component Structure
Reduce `page.tsx` to a data-loading and route-level shell.
Suggested structure:
```text
src/components/reservations/new/
├── ReservationWizard.tsx
├── ReservationStepper.tsx
├── ReservationWizardActions.tsx
├── ReservationReview.tsx
├── reservationWizard.types.ts
├── reservationWizard.reducer.ts
├── reservationWizard.schemas.ts
├── reservationWizard.copy.ts
├── reservationWizard.submit.ts
├── steps/
│ ├── CustomerStep.tsx
│ ├── IdentityStep.tsx
│ ├── LicenseStep.tsx
│ ├── RentalDetailsStep.tsx
│ ├── PaymentExtrasStep.tsx
│ └── ReviewStep.tsx
└── __tests__/
```
Responsibilities:
* `page.tsx`: load customers and vehicles, render the wizard.
* `ReservationWizard`: control current step and draft state.
* Step components: render fields only.
* Schemas: validation only.
* Reducer: state transitions only.
* Submission module: API orchestration only.
* Copy file: typed English, French, and Arabic labels.
* Review component: display formatted summaries only.
---
## 8. API Submission Strategy
No customer or reservation records should be created merely because the user moved to another step.
On final confirmation:
1. Validate the complete draft.
2. Revalidate vehicle availability.
3. For a new customer:
* Create the customer using the accumulated customer, identity, and license fields.
4. For an existing customer:
* Patch only changed customer fields.
5. Upload the license image when a new image was selected.
6. Create the reservation.
7. Redirect to the new reservation details page.
### Partial-failure handling
The current operations are not atomic. For example, customer creation may succeed while reservation creation fails.
The wizard must therefore:
* Remember the newly created customer ID in memory.
* Retry using that customer instead of creating another duplicate.
* Show which operation failed.
* Keep all form data available for correction or retry.
* Never redirect until reservation creation succeeds.
### Recommended backend improvement
The strongest solution is a dedicated backend operation that creates or updates the customer, uploads document metadata, validates vehicle availability, and creates the reservation as one controlled workflow.
Without such an endpoint, the frontend can handle retries carefully, but it cannot provide a true database transaction across the current independent requests.
---
## 9. Data Loading and Customer Hydration
Current customer and vehicle loading should be extracted from the form component.
Required behavior:
* Show a proper loading state for the wizard shell.
* Show separate customer-load and vehicle-load errors where possible.
* Do not overwrite user-edited customer data when background data refreshes.
* Hydrate fields only when the selected customer actually changes.
* Cancel or ignore stale requests when the component unmounts.
* Avoid loading arbitrary large lists indefinitely as the database grows.
Future improvement:
Replace `pageSize=100` customer loading with server-side customer search. Loading the first 100 customers and searching them in the browser will eventually fail as a business grows, which is traditionally when software chooses to reveal its assumptions.
---
## 10. Draft-Loss Protection
For the first implementation:
* Keep all information when navigating between wizard steps.
* Warn before leaving the page when unsaved changes exist.
* Do not persist license images in browser storage.
* Clear the draft after successful creation.
* Clear object URLs used for image previews.
Do not automatically store sensitive customer identity and license information in long-lived local storage.
A server-side draft feature can be designed separately if staff need to resume reservations across devices or sessions.
---
## 11. Accessibility and Localization
The wizard must preserve the applications existing multilingual requirements.
Requirements:
* English and French use LTR.
* Arabic uses true RTL.
* Step order and navigation layout adapt correctly in RTL.
* Every field has an associated label.
* Required fields are announced to assistive technology.
* Errors use `aria-describedby`.
* The active step uses `aria-current="step"`.
* Focus moves to the step heading after navigation.
* Keyboard users can operate all controls.
* Color is not the only indicator of progress or error.
* Buttons and inputs remain usable in dark mode.
* Dates and amounts are presented using the active locale on the review screen.
The current hardcoded English license-image alt text must also be localized.
---
## 12. Testing Plan
### Unit tests
Test:
* Reducer actions.
* Step validation schemas.
* Date relationships.
* Deposit normalization.
* Existing customer hydration.
* Switching customer modes.
* Additional-driver add and remove behavior.
* Final payload construction.
### Component tests
Test:
* Only the active step is displayed.
* Continue blocks invalid data.
* Back preserves data.
* Completed steps can be revisited.
* Existing customer details are prefilled.
* New customer information remains in draft state.
* Additional-driver fields appear conditionally.
* Edit actions on the review page open the correct step.
* Arabic step layout uses RTL correctly.
### Submission tests
Test:
* Existing customer reservation.
* New customer reservation.
* License image upload.
* Customer update failure.
* Image upload failure.
* Reservation creation failure after customer creation.
* Retry without duplicate customer creation.
* Double-click prevention.
* Vehicle availability conflict.
### Required verification
Run:
```bash
npm run type-check
npm run test
npm run build
```
for the dashboard workspace after implementation.
---
## 13. Implementation Sequence
### Phase 1: Extract and stabilize
* Create typed draft models.
* Extract copy, validation, and payload-building logic.
* Add tests around current behavior.
* Do not change the visible interface yet.
### Phase 2: Build the wizard shell
* Add stepper.
* Add reducer.
* Add navigation.
* Add shared action footer.
* Render one step at a time.
### Phase 3: Move form sections
* Move customer fields.
* Move identity fields.
* Move license fields.
* Move rental fields.
* Move payment and optional driver fields.
* Build the review screen.
### Phase 4: Correct submission behavior
* Delay new-customer creation until final confirmation.
* Add partial-failure recovery.
* Prevent duplicate requests.
* Revalidate vehicle availability.
* Preserve the draft after recoverable errors.
### Phase 5: Accessibility and responsive refinement
* Add keyboard and screen-reader behavior.
* Verify mobile presentation.
* Verify dark mode.
* Verify Arabic RTL.
* Verify translated error messages.
### Phase 6: Testing and regression review
* Complete unit and component tests.
* Run type-check, tests, and production build.
* Verify both existing-customer and new-customer flows manually.
* Confirm redirect to the created reservation.
---
## 14. Acceptance Criteria
The work is complete only when:
* Exactly one focused step is displayed at a time.
* The user can see their current position and progress.
* Invalid current-step fields prevent progression and explain why.
* Going backward does not lose information.
* New customers are not created before final confirmation.
* Existing-customer data is loaded without repeatedly overwriting edits.
* Additional drivers remain optional.
* The final review contains all reservation information.
* Every review section can be edited.
* Duplicate reservations cannot be created by repeated clicks.
* Partial API failures do not force the user to restart.
* Vehicle availability is revalidated for the selected dates.
* English, French, and Arabic are complete.
* Arabic layout is true RTL.
* Mobile, desktop, light mode, and dark mode work.
* Type-check, tests, and production build pass.
---
## 15. Scope Boundaries
Included:
* Progressive reservation wizard.
* Refactoring of the current new-reservation page.
* Step validation.
* Review screen.
* Existing and new customer flows.
* License image handling.
* Additional-driver handling.
* API failure recovery.
* Responsive, multilingual, and accessible behavior.
* Relevant tests.
Not included unless separately approved:
* Redesigning the reservation details page.
* Server-side saved drafts.
* OCR extraction from driver licenses.
* Multiple primary renters.
* Payment processing.
* Contract generation.
* Changing unrelated dashboard pages.
* Broad customer-management redesign.
@@ -0,0 +1,775 @@
# RentalDriveGo Dashboard Menu and Subscription Entitlements Plan
## Document status
- **Purpose:** Implementation plan only
- **Source code changes:** None
- **Target applications:** Dashboard, Admin, API, Database, Shared i18n package
- **Primary objective:** Make the initial dashboard sidebar match the approved seven-item menu while allowing platform administrators to control advanced modules by subscription plan.
- **Localization objective:** Provide one consistent terminology system for English (`en`), French (`fr`), and Arabic (`ar`), including true RTL behavior for Arabic.
---
## 1. Approved baseline sidebar
The default owner sidebar must contain exactly these items, in this order:
| Order | Menu item | System key | Route |
|---:|---|---|---|
| 10 | Dashboard | `dashboard` | `/` |
| 20 | Reservations | `reservations` | `/reservations` |
| 30 | Fleet | `fleet` | `/fleet` |
| 40 | Customers | `customers` | `/customers` |
| 50 | Reports | `reports` | `/reports` |
| 60 | Billing | `billing` | `/billing` |
| 70 | Settings | `settings` | `/settings` |
The existing sidebar styling, layout, branding, theme controls, language controls, responsive behavior, and RTL behavior must remain unchanged.
---
## 2. Advanced subscription modules
The following existing modules will remain available as configurable advanced features:
- Online Reservations
- Offers
- Team
- Contracts
- Notifications
- Reviews
- Complaints
Initial entitlement proposal:
| Module | STARTER | GROWTH | PRO |
|---|:---:|:---:|:---:|
| Dashboard | Yes | Yes | Yes |
| Reservations | Yes | Yes | Yes |
| Fleet | Yes | Yes | Yes |
| Customers | Yes | Yes | Yes |
| Reports | Yes | Yes | Yes |
| Billing | Yes | Yes | Yes |
| Settings | Yes | Yes | Yes |
| Online Reservations | No | Yes | Yes |
| Offers | No | Yes | Yes |
| Team | No | Yes | Yes |
| Contracts | No | Yes | Yes |
| Notifications | No | Yes | Yes |
| Reviews | No | No | Yes |
| Complaints | No | No | Yes |
The platform administrator may later change the advanced-module assignments. Core baseline modules remain protected.
---
## 3. Multilingual terminology and translation contract
All menu items must use immutable system keys and shared translation keys. Application logic must never depend on translated labels.
### 3.1 Approved menu terminology
| System key | Translation key | English (`en`) | French (`fr`) | Arabic (`ar`) |
|---|---|---|---|---|
| `dashboard` | `navigation.dashboard` | Dashboard | Tableau de bord | لوحة التحكم |
| `reservations` | `navigation.reservations` | Reservations | Réservations | الحجوزات |
| `fleet` | `navigation.fleet` | Fleet | Flotte | الأسطول |
| `customers` | `navigation.customers` | Customers | Clients | العملاء |
| `reports` | `navigation.reports` | Reports | Rapports | التقارير |
| `billing` | `navigation.billing` | Billing | Facturation | الفوترة |
| `settings` | `navigation.settings` | Settings | Paramètres | الإعدادات |
| `online-reservations` | `navigation.onlineReservations` | Online Reservations | Réservations en ligne | الحجوزات عبر الإنترنت |
| `offers` | `navigation.offers` | Offers | Offres | العروض |
| `team` | `navigation.team` | Team | Équipe | الفريق |
| `contracts` | `navigation.contracts` | Contracts | Contrats | العقود |
| `notifications` | `navigation.notifications` | Notifications | Notifications | الإشعارات |
| `reviews` | `navigation.reviews` | Reviews | Avis | التقييمات |
| `complaints` | `navigation.complaints` | Complaints | Réclamations | الشكاوى |
These translations are the approved product glossary. Core labels are protected and must not be edited casually from the Admin application.
### 3.2 Shared localization architecture
Recommended structure:
```text
packages/i18n/
├── locales/
│ ├── en.json
│ ├── fr.json
│ └── ar.json
├── glossary.ts
├── validation.ts
└── index.ts
```
Dashboard, Admin, and any future storefront or mobile application must consume the same translation package. Independent translation copies are prohibited because they create terminology drift.
Example menu definition:
```ts
{
systemKey: "reservations",
labelKey: "navigation.reservations",
route: "/reservations"
}
```
The database and API may store `systemKey` and `labelKey`. They must not use translated text as an identifier.
### 3.3 Translation publishing rules
- Core menu translations are locked.
- Custom modules require English, French, and Arabic labels before publishing.
- A label override must provide all three languages.
- Empty translations are rejected.
- Unknown or obsolete translation keys are rejected.
- Translation variables must match across all languages.
- Administrators may configure plan access, roles, order, status, and icons without modifying protected terminology.
- Only `SUPER_ADMIN` may approve changes to the shared product glossary.
### 3.4 Fallback behavior
The application fallback order is:
```text
Requested locale
→ English fallback in local development only
→ Validation or build failure before production deployment
```
Production must not silently display English labels inside French or Arabic interfaces.
### 3.5 Arabic RTL requirements
When Arabic is selected:
```html
<html lang="ar" dir="rtl">
```
When English or French is selected:
```html
<html lang="en" dir="ltr">
<html lang="fr" dir="ltr">
```
Arabic verification must include:
- Sidebar alignment
- Menu indentation
- Chevron direction
- Breadcrumb order
- Form and modal layout
- Text alignment
- Number formatting
- Date formatting
- Currency formatting
- Mobile navigation behavior
Only directional icons should be mirrored. Neutral icons such as Settings, Billing, Fleet, and Reports must keep their original orientation.
### 3.6 Locale-aware formatting
Dates, numbers, percentages, and currencies must use locale-aware formatting rather than translated strings.
```ts
new Intl.DateTimeFormat(locale).format(date);
new Intl.NumberFormat(locale, {
style: "currency",
currency: "MAD",
}).format(amount);
```
Stored values remain locale-independent.
---
## 4. Authorization rule
A module is available only when all required conditions are true:
```text
Module is globally enabled
AND module is included in the company's subscription plan
AND the company has not disabled the module
AND the employee role is allowed to use the module
AND the subscription status permits access
```
Company-level settings may disable or reorder entitled modules. They must not unlock a module outside the active subscription plan.
Sidebar visibility alone is not authorization. The API and direct routes must enforce the same entitlement rules.
---
## 5. Role behavior
Subscription access and employee-role access remain separate.
Recommended baseline policy:
| Module | OWNER | MANAGER | AGENT |
|---|:---:|:---:|:---:|
| Dashboard | Yes | Yes | Yes |
| Reservations | Yes | Yes | Yes |
| Fleet | Yes | Yes | Yes |
| Customers | Yes | Yes | Yes |
| Reports | Yes | Yes | Optional |
| Billing | Yes | Optional | No |
| Settings | Yes | Optional | No |
The final role matrix must be approved before implementation. The screenshot represents the owner-level default menu, not automatic access for every employee.
---
## 6. Subscription-status behavior
| Subscription status | Menu behavior |
|---|---|
| `ACTIVE` | Full entitled menu |
| `TRIALING` | Full entitled menu |
| `PAST_DUE` | Configurable restricted or read-only behavior |
| `SUSPENDED` | Recovery menu only |
| `EXPIRED` | Recovery menu only |
| `CANCELLED` | Recovery menu only |
| Missing subscription | Onboarding or recovery menu only |
Recommended recovery menu:
- Dashboard
- Billing
- Settings
This keeps account recovery possible without exposing paid operational modules.
---
## 7. Data model
### 7.1 Menu module catalog
Each module should define:
```text
id
systemKey
defaultLabel
localizedLabels
routeOrUrl
icon
defaultOrder
parentId
minimumRole or allowedRoles
isRequired
isGloballyEnabled
createdAt
updatedAt
```
### 7.2 Subscription-plan entitlement
```text
plan
menuItemId
enabled
displayOrder
```
### 7.3 Company override
```text
companyId
menuItemId
enabled
displayOrder
optionalLabelOverride
optionalIconOverride
```
### 7.4 Audit record
```text
actorId
action
targetType
targetId
before
after
reason
createdAt
```
---
## 8. Implementation phases
## Phase 1: Confirm the menu contract
### Work
- Approve the seven baseline items.
- Approve the initial advanced-module plan matrix.
- Approve the employee-role matrix.
- Define protected system fields.
- Define subscription-status behavior.
- Confirm whether companies may customize labels and icons.
- Approve the English, French, and Arabic terminology glossary.
- Confirm which translation fields are protected.
### Deliverable
A frozen entitlement matrix and protected-module contract.
### Exit criteria
- No unresolved module ownership questions.
- No unresolved role-access questions.
- No unresolved downgrade behavior.
- No unresolved translation or RTL terminology decisions.
---
## Phase 2: Database migration and seed
### Work
- Create or update the menu catalog.
- Seed the seven baseline modules.
- Assign the baseline modules explicitly to `STARTER`, `GROWTH`, and `PRO`.
- Seed the advanced-module plan assignments.
- Normalize menu order.
- Create company-override records only where required.
- Add unique constraints for `systemKey`.
- Add audit support if missing.
- Detect and report existing assignments that exceed subscription access.
- Make the migration idempotent.
### Deliverable
Database migration, seed, and rollback procedure.
### Exit criteria
- Re-running the migration causes no duplicates.
- No navigable module relies on implicit plan access.
- Existing companies receive deterministic menus.
---
## Phase 3: Central entitlement resolver in the API
### Work
Create one canonical entitlement service used by both menu generation and API authorization.
Suggested responsibility:
```ts
resolveModuleAccess({
companyId,
employeeId,
systemKey,
})
```
The resolver must evaluate:
- Global module state
- Active subscription plan
- Subscription status
- Explicit plan entitlement
- Company override
- Employee role
- Required recovery access
### Deliverable
A reusable entitlement service with unit tests.
### Exit criteria
- Menu generation and API guards use the same decision logic.
- Company overrides cannot grant higher-plan access.
- Missing plan assignments mean unavailable.
---
## Phase 4: Harden menu-management API
### Work
- Protect required modules.
- Prevent ordinary administrators from changing:
- `systemKey`
- Core routes
- Required status
- Core plan assignments
- Core role assignments
- Parent relationships
- Validate unique system keys.
- Prevent circular parent relationships.
- Require explicit plan selection for new navigable modules.
- Record all changes in the audit log.
- Require a reason and expiration for exceptional plan overrides.
- Add clear validation errors.
### Deliverable
Secure admin menu endpoints and boundary tests.
### Exit criteria
- Required modules cannot be weakened through general update endpoints.
- Invalid menu trees are rejected.
- All entitlement-changing actions are auditable.
---
## Phase 5: Enforce advanced-module API access
### Work
Apply reusable entitlement middleware to advanced modules:
```ts
requireModuleEntitlement("online-reservations")
requireModuleEntitlement("offers")
requireModuleEntitlement("team")
requireModuleEntitlement("contracts")
requireModuleEntitlement("notifications")
requireModuleEntitlement("reviews")
requireModuleEntitlement("complaints")
```
Also enforce baseline role restrictions where applicable.
### Deliverable
Server-side module protection across all relevant routes.
### Exit criteria
- Entering a hidden route manually does not bypass access.
- Calling an advanced API directly does not bypass the subscription.
- Unauthorized responses are consistent and testable.
---
## Phase 6: Refine the Admin menu-management interface
### Work
Create a clear plan matrix as the primary management view:
```text
Module | STARTER | GROWTH | PRO | Roles | Enabled | Order
```
Add:
- Plan tabs or columns
- Required-item locks
- Role controls
- Ordering controls
- Company and role preview
- Subscription-status preview
- Audit history
- Warning before removing a module used by active companies
- Supported icon selector instead of unrestricted icon text
- Advanced configuration restricted to `SUPER_ADMIN`
### Deliverable
A safer, plan-oriented administration interface.
### Exit criteria
- An administrator can configure advanced subscriptions without editing routes or system keys.
- The preview matches the actual dashboard menu.
- Protected items are visibly locked.
---
## Phase 7: Update the Dashboard sidebar
### Work
- Replace the large hardcoded fallback with the seven approved baseline items.
- Preserve the exact approved order.
- Keep the API-generated menu as the source of truth.
- Distinguish:
- Loading state
- API failure
- Successful empty response
- Successful populated response
- Treat a successful empty response as intentional.
- Never restore advanced modules after an empty response.
- Use a role-aware safe fallback only when the API genuinely fails.
- Remove the separate Subscription item from the initial menu.
- Keep subscription access available through Billing or Settings.
- Preserve visual design and unrelated behavior.
### Deliverable
A deterministic sidebar matching the approved design.
### Exit criteria
- A `STARTER` owner sees exactly seven items.
- No menu flicker exposes restricted modules.
- RTL, mobile, theme, and localization behavior remain intact.
---
## Phase 8: Route guards in Dashboard and Admin
### Work
- Align frontend route guards with API entitlements.
- Show a clear access-denied or upgrade-required screen.
- Do not silently redirect users to unrelated pages.
- Prevent stale cached menus after plan or role changes.
- Invalidate menu data after admin updates.
### Deliverable
Consistent frontend route protection.
### Exit criteria
- Hidden modules cannot be opened by entering their URL.
- Downgrades remove route access without requiring a new login.
- Upgrade messaging is explicit and accurate.
---
## Phase 9: Implement shared localization validation
### Work
- Create or update the shared `packages/i18n` package.
- Add the approved `en`, `fr`, and `ar` navigation keys.
- Replace hardcoded menu labels in Dashboard and Admin with translation keys.
- Validate that all locale files contain the same required keys.
- Validate that interpolation variables match across languages.
- Reject empty, unknown, and obsolete keys.
- Add CI checks for translation completeness.
- Add development logging for missing keys.
- Ensure Arabic applies `lang="ar"` and `dir="rtl"` at the document root.
- Verify locale-aware dates, numbers, and MAD currency formatting.
- Prevent translated labels from being used in permissions, routes, plan assignments, or database lookups.
### Deliverable
Shared translation package, approved glossary, validation script, and CI enforcement.
### Exit criteria
- Every required English key exists in French and Arabic.
- No navigation label is hardcoded in Dashboard or Admin.
- Core glossary terms are protected.
- Arabic uses true RTL layout.
- Missing translations fail CI before deployment.
---
## Phase 10: Testing
### API tests
- Explicit plan assignment is required.
- Company override cannot upgrade a plan.
- Required items cannot be disabled by ordinary admins.
- Suspended subscriptions receive only recovery access.
- Role restrictions remain active.
- Advanced APIs reject unauthorized requests.
- Audit entries are created.
### Localization tests
- Every navigation key exists in English, French, and Arabic.
- Core terminology matches the approved glossary exactly.
- Missing or empty translations fail CI.
- Interpolation variables match across locales.
- Switching language does not change routes, permissions, subscription access, or system keys.
- French labels do not overflow.
- Arabic labels do not truncate.
- Arabic sets `lang="ar"` and `dir="rtl"`.
- Directional icons mirror correctly and neutral icons do not.
- Dates, numbers, and MAD currency values format correctly by locale.
### Dashboard tests
- `STARTER` owner sees exactly seven items.
- Approved order is preserved.
- Empty successful response remains empty.
- API failure uses only the safe fallback.
- Advanced modules appear only when entitled.
- Direct routes are blocked.
- Mobile and desktop menus behave consistently.
- EN, FR, and AR labels render correctly.
- Arabic remains true RTL.
### Admin tests
- Plan toggles save correctly.
- Locked modules cannot be modified.
- Preview matches the generated employee menu.
- Invalid parent relationships are rejected.
- Removing a widely used entitlement produces a warning.
- Audit history displays the change.
### Deliverable
Unit, integration, boundary, and end-to-end test coverage.
---
## Phase 11: Deployment and rollback
### Deployment order
1. Database migration and seed
2. API entitlement resolver
3. API route enforcement
4. Admin management interface
5. Dashboard sidebar and route guards
6. End-to-end verification
### Rollback requirements
- Database rollback or compensating migration
- Feature flag for new entitlement enforcement
- Ability to restore previous menu assignments
- Audit record of rollback actions
- No destructive deletion of historical menu configuration
### Exit criteria
- Production smoke tests pass.
- Existing companies retain expected access.
- No restricted module is exposed.
- Recovery access remains available.
---
## 9. File-level work areas
Expected targets, subject to repository verification:
### Dashboard
```text
src/components/layout/Sidebar.tsx
src/components/layout/DashboardAccessGuard.tsx
src/components/layout/Sidebar.boundary.test.ts
src/components/layout/DashboardAccessGuard.boundary.test.ts
```
### Admin
```text
src/app/dashboard/menu-management/page.tsx
related menu-management components
related API client modules
related tests
```
### API
```text
src/modules/menu/menu.service.ts
src/modules/menu/menu.entitlement.ts
src/middleware/requireModuleEntitlement.ts
menu administration routes
advanced-module routes
related unit, boundary, and E2E tests
```
### Database
```text
Prisma schema
menu seed
subscription entitlement seed
migration
rollback or repair script
```
### Shared i18n package
```text
packages/i18n/locales/en.json
packages/i18n/locales/fr.json
packages/i18n/locales/ar.json
packages/i18n/glossary.ts
packages/i18n/validation.ts
packages/i18n/index.ts
related localization tests
```
No file should be changed until the repository structure is verified against these expected paths.
---
## 10. Non-goals
This milestone will not:
- Redesign the sidebar visually.
- Change dashboard branding.
- Change the theme system.
- Change localization architecture.
- Replace existing pages.
- Introduce machine translation at runtime.
- Allow translated labels to control routes, permissions, or entitlements.
- Add unrelated subscription billing features.
- Grant tenant administrators permission to alter platform routes.
- Treat hidden navigation as sufficient security.
---
## 11. Definition of done
The implementation is complete only when:
- The initial owner sidebar contains exactly the seven approved items.
- The order matches the approved design.
- Advanced items are explicitly assigned by subscription plan.
- Company overrides cannot bypass subscription entitlements.
- Employee roles remain enforced.
- Subscription status affects access.
- Direct frontend routes are protected.
- Direct API calls are protected.
- Required system items cannot be weakened by ordinary administrators.
- Admin previews match actual dashboard results.
- All changes are audited.
- Existing visual behavior remains unchanged.
- English, French, and Arabic use the approved shared terminology.
- Arabic uses true RTL behavior.
- Missing or inconsistent translations fail CI.
- Routes, permissions, subscriptions, and API logic use immutable system keys rather than translated labels.
- Tests pass across Dashboard, Admin, API, and Database.
- Deployment and rollback procedures are documented.
---
## 12. Execution gate
Implementation must not begin until these items are approved:
1. Baseline seven-item menu
2. Advanced-module plan matrix
3. Employee-role matrix
4. Subscription-status policy
5. Company customization limits
6. Required-item protection rules
7. Database migration strategy
8. Approved EN/FR/AR glossary
9. Translation fallback and CI policy
10. Arabic RTL acceptance criteria
This prevents the usual ritual of coding first and discovering the business rules afterward.
+120
View File
@@ -11651,6 +11651,126 @@
"devDependencies": { "devDependencies": {
"typescript": "^5.4.0" "typescript": "^5.4.0"
} }
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz",
"integrity": "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz",
"integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz",
"integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz",
"integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz",
"integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz",
"integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz",
"integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz",
"integrity": "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
} }
} }
} }
@@ -0,0 +1,254 @@
UPDATE "menu_items"
SET "systemKey" = 'online-reservations',
"updatedBy" = 'system',
"updatedAt" = CURRENT_TIMESTAMP
WHERE "systemKey" = 'onlineReservations'
AND NOT EXISTS (
SELECT 1 FROM "menu_items" WHERE "systemKey" = 'online-reservations'
);
WITH canonical_menu_items (
"systemKey",
"label",
"itemType",
"routeOrUrl",
"icon",
"displayOrder",
"isRequired",
"isActive"
) AS (
VALUES
('dashboard', 'Dashboard', 'INTERNAL_PAGE'::"MenuItemType", '/', 'LayoutDashboard', 10, true, true),
('reservations', 'Reservations', 'INTERNAL_PAGE'::"MenuItemType", '/reservations', 'Calendar', 20, true, true),
('fleet', 'Fleet', 'INTERNAL_PAGE'::"MenuItemType", '/fleet', 'Car', 30, true, true),
('customers', 'Customers', 'INTERNAL_PAGE'::"MenuItemType", '/customers', 'Users', 40, true, true),
('reports', 'Reports', 'INTERNAL_PAGE'::"MenuItemType", '/reports', 'BarChart2', 50, true, true),
('billing', 'Billing', 'INTERNAL_PAGE'::"MenuItemType", '/billing', 'CreditCard', 60, true, true),
('settings', 'Settings', 'INTERNAL_PAGE'::"MenuItemType", '/settings', 'Settings', 70, true, true),
('online-reservations', 'Online Reservations', 'INTERNAL_PAGE'::"MenuItemType", '/online-reservations', 'Globe', 80, false, true),
('offers', 'Offers', 'INTERNAL_PAGE'::"MenuItemType", '/offers', 'Tag', 90, false, true),
('team', 'Team', 'INTERNAL_PAGE'::"MenuItemType", '/team', 'UserPlus', 100, false, true),
('contracts', 'Contracts', 'INTERNAL_PAGE'::"MenuItemType", '/contracts', 'FileText', 110, false, true),
('notifications', 'Notifications', 'INTERNAL_PAGE'::"MenuItemType", '/notifications', 'Bell', 120, false, true),
('reviews', 'Reviews', 'INTERNAL_PAGE'::"MenuItemType", '/reviews', 'Star', 130, false, true),
('complaints', 'Complaints', 'INTERNAL_PAGE'::"MenuItemType", '/complaints', 'AlertTriangle', 140, false, true),
('subscription', 'Subscription', 'INTERNAL_PAGE'::"MenuItemType", '/subscription', 'CreditCard', 999, false, false)
)
INSERT INTO "menu_items" (
"id",
"systemKey",
"label",
"itemType",
"routeOrUrl",
"icon",
"displayOrder",
"openInNewTab",
"isRequired",
"isActive",
"createdAt",
"updatedAt",
"createdBy",
"updatedBy"
)
SELECT
'menu_' || "systemKey",
"systemKey",
"label",
"itemType",
"routeOrUrl",
"icon",
"displayOrder",
false,
"isRequired",
"isActive",
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP,
'system',
'system'
FROM canonical_menu_items
ON CONFLICT ("systemKey") DO UPDATE
SET
"label" = EXCLUDED."label",
"itemType" = EXCLUDED."itemType",
"routeOrUrl" = EXCLUDED."routeOrUrl",
"icon" = EXCLUDED."icon",
"displayOrder" = EXCLUDED."displayOrder",
"isRequired" = EXCLUDED."isRequired",
"isActive" = EXCLUDED."isActive",
"updatedBy" = 'system',
"updatedAt" = CURRENT_TIMESTAMP;
DELETE FROM "company_menu_items"
WHERE "menuItemId" IN (
SELECT "id"
FROM "menu_items"
WHERE "systemKey" = 'subscription'
);
DELETE FROM "subscription_menu_items"
WHERE "menuItemId" IN (
SELECT "id"
FROM "menu_items"
WHERE "systemKey" IN (
'dashboard',
'reservations',
'fleet',
'customers',
'reports',
'billing',
'settings',
'online-reservations',
'onlineReservations',
'offers',
'team',
'contracts',
'notifications',
'reviews',
'complaints',
'subscription'
)
);
WITH canonical_subscription_assignments ("systemKey", "plan", "displayOrder") AS (
VALUES
('dashboard', 'STARTER'::"Plan", 10),
('reservations', 'STARTER'::"Plan", 20),
('fleet', 'STARTER'::"Plan", 30),
('customers', 'STARTER'::"Plan", 40),
('reports', 'STARTER'::"Plan", 50),
('billing', 'STARTER'::"Plan", 60),
('settings', 'STARTER'::"Plan", 70),
('dashboard', 'GROWTH'::"Plan", 10),
('reservations', 'GROWTH'::"Plan", 20),
('fleet', 'GROWTH'::"Plan", 30),
('customers', 'GROWTH'::"Plan", 40),
('reports', 'GROWTH'::"Plan", 50),
('billing', 'GROWTH'::"Plan", 60),
('settings', 'GROWTH'::"Plan", 70),
('online-reservations', 'GROWTH'::"Plan", 80),
('offers', 'GROWTH'::"Plan", 90),
('team', 'GROWTH'::"Plan", 100),
('contracts', 'GROWTH'::"Plan", 110),
('notifications', 'GROWTH'::"Plan", 120),
('dashboard', 'PRO'::"Plan", 10),
('reservations', 'PRO'::"Plan", 20),
('fleet', 'PRO'::"Plan", 30),
('customers', 'PRO'::"Plan", 40),
('reports', 'PRO'::"Plan", 50),
('billing', 'PRO'::"Plan", 60),
('settings', 'PRO'::"Plan", 70),
('online-reservations', 'PRO'::"Plan", 80),
('offers', 'PRO'::"Plan", 90),
('team', 'PRO'::"Plan", 100),
('contracts', 'PRO'::"Plan", 110),
('notifications', 'PRO'::"Plan", 120),
('reviews', 'PRO'::"Plan", 130),
('complaints', 'PRO'::"Plan", 140)
)
INSERT INTO "subscription_menu_items" (
"id",
"plan",
"menuItemId",
"displayOrder",
"isActive",
"createdAt",
"updatedAt"
)
SELECT
'menu_sub_' || lower(csa."plan"::text) || '_' || csa."systemKey",
csa."plan",
mi."id",
csa."displayOrder",
true,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM canonical_subscription_assignments csa
JOIN "menu_items" mi ON mi."systemKey" = csa."systemKey"
ON CONFLICT ("plan", "menuItemId") DO UPDATE
SET
"displayOrder" = EXCLUDED."displayOrder",
"isActive" = true,
"updatedAt" = CURRENT_TIMESTAMP;
DELETE FROM "menu_item_role_visibility"
WHERE "menuItemId" IN (
SELECT "id"
FROM "menu_items"
WHERE "systemKey" IN (
'dashboard',
'reservations',
'fleet',
'customers',
'reports',
'billing',
'settings',
'online-reservations',
'onlineReservations',
'offers',
'team',
'contracts',
'notifications',
'reviews',
'complaints',
'subscription'
)
);
WITH canonical_role_visibilities ("systemKey", "role") AS (
VALUES
('dashboard', 'OWNER'::"EmployeeRole"),
('dashboard', 'MANAGER'::"EmployeeRole"),
('dashboard', 'AGENT'::"EmployeeRole"),
('reservations', 'OWNER'::"EmployeeRole"),
('reservations', 'MANAGER'::"EmployeeRole"),
('reservations', 'AGENT'::"EmployeeRole"),
('fleet', 'OWNER'::"EmployeeRole"),
('fleet', 'MANAGER'::"EmployeeRole"),
('fleet', 'AGENT'::"EmployeeRole"),
('customers', 'OWNER'::"EmployeeRole"),
('customers', 'MANAGER'::"EmployeeRole"),
('customers', 'AGENT'::"EmployeeRole"),
('reports', 'OWNER'::"EmployeeRole"),
('reports', 'MANAGER'::"EmployeeRole"),
('billing', 'OWNER'::"EmployeeRole"),
('billing', 'MANAGER'::"EmployeeRole"),
('settings', 'OWNER'::"EmployeeRole"),
('online-reservations', 'OWNER'::"EmployeeRole"),
('online-reservations', 'MANAGER'::"EmployeeRole"),
('online-reservations', 'AGENT'::"EmployeeRole"),
('offers', 'OWNER'::"EmployeeRole"),
('offers', 'MANAGER'::"EmployeeRole"),
('team', 'OWNER'::"EmployeeRole"),
('team', 'MANAGER'::"EmployeeRole"),
('team', 'AGENT'::"EmployeeRole"),
('contracts', 'OWNER'::"EmployeeRole"),
('contracts', 'MANAGER'::"EmployeeRole"),
('contracts', 'AGENT'::"EmployeeRole"),
('notifications', 'OWNER'::"EmployeeRole"),
('notifications', 'MANAGER'::"EmployeeRole"),
('notifications', 'AGENT'::"EmployeeRole"),
('reviews', 'OWNER'::"EmployeeRole"),
('reviews', 'MANAGER'::"EmployeeRole"),
('reviews', 'AGENT'::"EmployeeRole"),
('complaints', 'OWNER'::"EmployeeRole"),
('complaints', 'MANAGER'::"EmployeeRole"),
('complaints', 'AGENT'::"EmployeeRole")
)
INSERT INTO "menu_item_role_visibility" (
"id",
"menuItemId",
"role",
"createdAt",
"updatedAt"
)
SELECT
'menu_role_' || lower(crv."role"::text) || '_' || crv."systemKey",
mi."id",
crv."role",
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM canonical_role_visibilities crv
JOIN "menu_items" mi ON mi."systemKey" = crv."systemKey"
ON CONFLICT ("menuItemId", "role") DO NOTHING;