make the booking in many steps
Build & Deploy / Build & Push Docker Image (push) Successful in 8m27s
Test / Type Check (all packages) (push) Successful in 3m34s
Build & Deploy / Deploy to VPS (push) Successful in 2s
Test / API Unit Tests (push) Failing after 3m16s
Test / Homepage Unit Tests (push) Failing after 2m40s
Test / Storefront Unit Tests (push) Failing after 24s
Test / Admin Unit Tests (push) Failing after 22s
Test / Dashboard Unit Tests (push) Failing after 24s
Test / API Integration Tests (push) Failing after 33s
Build & Deploy / Build & Push Docker Image (push) Successful in 8m27s
Test / Type Check (all packages) (push) Successful in 3m34s
Build & Deploy / Deploy to VPS (push) Successful in 2s
Test / API Unit Tests (push) Failing after 3m16s
Test / Homepage Unit Tests (push) Failing after 2m40s
Test / Storefront Unit Tests (push) Failing after 24s
Test / Admin Unit Tests (push) Failing after 22s
Test / Dashboard Unit Tests (push) Failing after 24s
Test / API Integration Tests (push) Failing after 33s
This commit is contained in:
@@ -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 l’espace, sans réservation en ligne préalable.',
|
|
||||||
customer: 'Client',
|
|
||||||
searchCustomer: 'Rechercher un client existant',
|
|
||||||
vehicle: 'Véhicule',
|
|
||||||
startDate: 'Date et heure de départ',
|
|
||||||
endDate: 'Date et heure de retour',
|
|
||||||
pickup: 'Lieu de départ',
|
|
||||||
return: 'Lieu de retour',
|
|
||||||
deposit: 'Montant du dépôt (MAD)',
|
|
||||||
paymentMode: 'Mode de paiement',
|
|
||||||
renterIdentity: 'Identité du locataire',
|
|
||||||
driverLicenseInfo: 'Informations du permis',
|
|
||||||
driverLicense: 'Numéro du permis',
|
|
||||||
licenseExpiry: 'Expiration du permis',
|
|
||||||
licenseIssuedAt: 'Permis délivré le',
|
|
||||||
licenseCountry: 'Pays du permis',
|
|
||||||
licenseCategory: 'Catégorie du permis',
|
|
||||||
fullAddress: 'Adresse complète',
|
|
||||||
identityDocumentNumber: 'N° CIN / passeport',
|
|
||||||
internationalLicenseNumber: 'N° de permis international',
|
|
||||||
licenseImage: 'Image du permis',
|
|
||||||
licenseImageHint: 'Téléversez une photo ou un scan du permis du conducteur principal.',
|
|
||||||
licenseImageSelected: 'Fichier sélectionné',
|
|
||||||
licenseImageCurrent: 'Image actuelle',
|
|
||||||
noLicenseImage: 'Aucune image de permis téléversée.',
|
|
||||||
addAdditionalDriver: 'Ajouter un conducteur supplémentaire',
|
|
||||||
additionalDriverInfo: 'Conducteur supplémentaire',
|
|
||||||
dateOfBirth: 'Date de naissance',
|
|
||||||
nationality: 'Nationalité',
|
|
||||||
notes: 'Notes',
|
|
||||||
notesPlaceholder: 'Notes optionnelles…',
|
|
||||||
create: 'Créer la réservation',
|
|
||||||
creating: 'Création…',
|
|
||||||
cancel: 'Annuler',
|
|
||||||
loadFailed: 'Échec du chargement des clients/véhicules.',
|
|
||||||
invalidDates: 'La date de fin doit être après la date de début.',
|
|
||||||
required: 'Veuillez remplir tous les champs requis.',
|
|
||||||
selectCustomer: 'Sélectionner un client…',
|
|
||||||
addCustomer: 'Ajouter un client',
|
|
||||||
addCustomerTitle: 'Ajouter un nouveau client',
|
|
||||||
firstName: 'Prénom',
|
|
||||||
lastName: 'Nom',
|
|
||||||
email: 'Email',
|
|
||||||
phone: 'Téléphone',
|
|
||||||
saveCustomer: 'Enregistrer le client',
|
|
||||||
savingCustomer: 'Enregistrement du client…',
|
|
||||||
selectVehicle: 'Sélectionner un véhicule…',
|
|
||||||
noVehicles: 'Aucun véhicule disponible.',
|
|
||||||
paymentModes: {
|
|
||||||
CASH: 'Espèces',
|
|
||||||
CARD: 'Carte',
|
|
||||||
BANK_TRANSFER: 'Virement',
|
|
||||||
AMANPAY: 'AmanPay',
|
|
||||||
PAYPAL: 'PayPal',
|
|
||||||
} as Record<string, string>,
|
|
||||||
},
|
|
||||||
ar: {
|
|
||||||
title: 'حجز محلي',
|
|
||||||
subtitle: 'أنشئ حجزًا مباشرة من مساحة العمل بدون حجز إلكتروني مسبق.',
|
|
||||||
customer: 'العميل',
|
|
||||||
searchCustomer: 'ابحث عن عميل سابق',
|
|
||||||
vehicle: 'المركبة',
|
|
||||||
startDate: 'تاريخ ووقت البداية',
|
|
||||||
endDate: 'تاريخ ووقت النهاية',
|
|
||||||
pickup: 'موقع الاستلام',
|
|
||||||
return: 'موقع التسليم',
|
|
||||||
deposit: 'مبلغ العربون (MAD)',
|
|
||||||
paymentMode: 'طريقة الدفع',
|
|
||||||
renterIdentity: 'بيانات المستأجر',
|
|
||||||
driverLicenseInfo: 'معلومات رخصة القيادة',
|
|
||||||
driverLicense: 'رقم رخصة القيادة',
|
|
||||||
licenseExpiry: 'تاريخ انتهاء الرخصة',
|
|
||||||
licenseIssuedAt: 'تاريخ إصدار الرخصة',
|
|
||||||
licenseCountry: 'بلد الرخصة',
|
|
||||||
licenseCategory: 'فئة الرخصة',
|
|
||||||
fullAddress: 'العنوان الكامل',
|
|
||||||
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
|
|
||||||
internationalLicenseNumber: 'رقم الرخصة الدولية',
|
|
||||||
licenseImage: 'صورة رخصة القيادة',
|
|
||||||
licenseImageHint: 'ارفع صورة أو مسحًا لرخصة السائق الأساسي.',
|
|
||||||
licenseImageSelected: 'الملف المحدد',
|
|
||||||
licenseImageCurrent: 'الصورة الحالية',
|
|
||||||
noLicenseImage: 'لم يتم رفع صورة الرخصة بعد.',
|
|
||||||
addAdditionalDriver: 'إضافة سائق إضافي',
|
|
||||||
additionalDriverInfo: 'السائق الإضافي',
|
|
||||||
dateOfBirth: 'تاريخ الميلاد',
|
|
||||||
nationality: 'الجنسية',
|
|
||||||
notes: 'ملاحظات',
|
|
||||||
notesPlaceholder: 'ملاحظات اختيارية…',
|
|
||||||
create: 'إنشاء الحجز',
|
|
||||||
creating: 'جارٍ الإنشاء…',
|
|
||||||
cancel: 'إلغاء',
|
|
||||||
loadFailed: 'فشل تحميل العملاء/المركبات.',
|
|
||||||
invalidDates: 'يجب أن يكون تاريخ النهاية بعد تاريخ البداية.',
|
|
||||||
required: 'يرجى تعبئة جميع الحقول المطلوبة.',
|
|
||||||
selectCustomer: 'اختر عميلًا…',
|
|
||||||
addCustomer: 'إضافة عميل',
|
|
||||||
addCustomerTitle: 'إضافة عميل جديد',
|
|
||||||
firstName: 'الاسم الأول',
|
|
||||||
lastName: 'اسم العائلة',
|
|
||||||
email: 'البريد الإلكتروني',
|
|
||||||
phone: 'الهاتف',
|
|
||||||
saveCustomer: 'حفظ العميل',
|
|
||||||
savingCustomer: 'جارٍ حفظ العميل…',
|
|
||||||
selectVehicle: 'اختر مركبة…',
|
|
||||||
noVehicles: 'لا توجد مركبات متاحة.',
|
|
||||||
paymentModes: {
|
|
||||||
CASH: 'نقدا',
|
|
||||||
CARD: 'بطاقة',
|
|
||||||
BANK_TRANSFER: 'تحويل بنكي',
|
|
||||||
AMANPAY: 'AmanPay',
|
|
||||||
PAYPAL: 'PayPal',
|
|
||||||
} as Record<string, string>,
|
|
||||||
},
|
|
||||||
}[language]
|
|
||||||
|
|
||||||
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 n’est marqué Disponible.'
|
|
||||||
: language === 'ar'
|
|
||||||
? 'توجد مركبات في الأسطول، لكن لا توجد مركبة بحالة متاحة.'
|
|
||||||
: 'Vehicles exist in fleet, but none are marked Available.'}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
|
|
||||||
<p className="text-sm font-semibold text-slate-900">{copy.renterIdentity}</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.dateOfBirth} <span className="text-red-600">*</span></span>
|
|
||||||
<input type="date" value={customerDateOfBirth} onChange={(e) => setCustomerDateOfBirth(e.target.value)} className="input-field" />
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.nationality} <span className="text-red-600">*</span></span>
|
|
||||||
<input value={customerNationality} onChange={(e) => setCustomerNationality(e.target.value)} className="input-field" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.identityDocumentNumber} <span className="text-red-600">*</span></span>
|
|
||||||
<input value={customerIdentityDocumentNumber} onChange={(e) => setCustomerIdentityDocumentNumber(e.target.value)} className="input-field" />
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.internationalLicenseNumber}</span>
|
|
||||||
<input value={customerInternationalLicenseNumber} onChange={(e) => setCustomerInternationalLicenseNumber(e.target.value)} className="input-field" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="space-y-1 block">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.fullAddress} <span className="text-red-600">*</span></span>
|
|
||||||
<textarea value={customerFullAddress} onChange={(e) => setCustomerFullAddress(e.target.value)} className="input-field min-h-[88px]" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
|
|
||||||
<p className="text-sm font-semibold text-slate-900">{copy.driverLicenseInfo}</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.driverLicense} <span className="text-red-600">*</span></span>
|
|
||||||
<input value={driverLicense} onChange={(e) => setDriverLicense(e.target.value)} className="input-field" />
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.licenseCountry} <span className="text-red-600">*</span></span>
|
|
||||||
<input value={licenseCountry} onChange={(e) => setLicenseCountry(e.target.value)} className="input-field" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.licenseIssuedAt} <span className="text-red-600">*</span></span>
|
|
||||||
<input type="date" value={licenseIssuedAt} onChange={(e) => setLicenseIssuedAt(e.target.value)} className="input-field" />
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.licenseExpiry} <span className="text-red-600">*</span></span>
|
|
||||||
<input type="date" value={licenseExpiry} onChange={(e) => setLicenseExpiry(e.target.value)} className="input-field" />
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.licenseCategory} <span className="text-red-600">*</span></span>
|
|
||||||
<input value={licenseCategory} onChange={(e) => setLicenseCategory(e.target.value)} className="input-field" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="space-y-1 block">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.licenseImage}</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={(e) => setLicenseImageFile(e.target.files?.[0] ?? null)}
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<p className="text-xs text-slate-500">{copy.licenseImageHint}</p>
|
|
||||||
{licenseImageFile ? (
|
|
||||||
<p className="text-xs font-medium text-slate-700">{copy.licenseImageSelected}: {licenseImageFile.name}</p>
|
|
||||||
) : licenseImageUrl ? (
|
|
||||||
<p className="text-xs font-medium text-slate-700">{copy.licenseImageCurrent}</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-slate-500">{copy.noLicenseImage}</p>
|
|
||||||
)}
|
|
||||||
{licenseImagePreviewUrl || licenseImageUrl ? (
|
|
||||||
<img
|
|
||||||
src={licenseImagePreviewUrl ?? licenseImageUrl ?? ''}
|
|
||||||
alt="Driver license preview"
|
|
||||||
className="h-40 w-full max-w-sm rounded-xl border border-slate-200 object-cover"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.startDate} <span className="text-red-600">*</span></span>
|
|
||||||
<input type="datetime-local" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="input-field" />
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.endDate} <span className="text-red-600">*</span></span>
|
|
||||||
<input type="datetime-local" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="input-field" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.pickup}</span>
|
|
||||||
<input value={pickupLocation} onChange={(e) => setPickupLocation(e.target.value)} className="input-field" />
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.return}</span>
|
|
||||||
<input value={returnLocation} onChange={(e) => setReturnLocation(e.target.value)} className="input-field" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="space-y-1 block">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.deposit}</span>
|
|
||||||
<input type="number" min={0} value={depositAmount} onChange={(e) => setDepositAmount(e.target.value)} className="input-field" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="space-y-1 block">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.paymentMode}</span>
|
|
||||||
<select value={paymentMode} onChange={(e) => setPaymentMode(e.target.value)} className="input-field">
|
|
||||||
{(['CASH', 'CARD', 'BANK_TRANSFER', 'AMANPAY', 'PAYPAL'] as const).map((mode) => (
|
|
||||||
<option key={mode} value={mode}>{copy.paymentModes[mode]}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
|
|
||||||
<label className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={includeAdditionalDriver}
|
|
||||||
onChange={(e) => setIncludeAdditionalDriver(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded border-slate-300 text-blue-600"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-slate-900">{copy.addAdditionalDriver}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{includeAdditionalDriver ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm font-semibold text-slate-900">{copy.additionalDriverInfo}</p>
|
|
||||||
<BilingualInput label={copy.firstName} required value={additionalDriver.firstName} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, firstName: v }))} />
|
|
||||||
<BilingualInput label={copy.lastName} required value={additionalDriver.lastName} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, lastName: v }))} />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.email}</span>
|
|
||||||
<input
|
|
||||||
value={additionalDriver.email}
|
|
||||||
onChange={(e) => setAdditionalDriver((current) => ({ ...current, email: e.target.value }))}
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.phone}</span>
|
|
||||||
<input
|
|
||||||
value={additionalDriver.phone}
|
|
||||||
onChange={(e) => setAdditionalDriver((current) => ({ ...current, phone: e.target.value }))}
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.driverLicense} <span className="text-red-600">*</span></span>
|
|
||||||
<input
|
|
||||||
value={additionalDriver.driverLicense}
|
|
||||||
onChange={(e) => setAdditionalDriver((current) => ({ ...current, driverLicense: e.target.value }))}
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<BilingualInput label={copy.nationality} value={additionalDriver.nationality} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, nationality: v }))} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.dateOfBirth}</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={additionalDriver.dateOfBirth}
|
|
||||||
onChange={(e) => setAdditionalDriver((current) => ({ ...current, dateOfBirth: e.target.value }))}
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.licenseIssuedAt}</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={additionalDriver.licenseIssuedAt}
|
|
||||||
onChange={(e) => setAdditionalDriver((current) => ({ ...current, licenseIssuedAt: e.target.value }))}
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.licenseExpiry}</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={additionalDriver.licenseExpiry}
|
|
||||||
onChange={(e) => setAdditionalDriver((current) => ({ ...current, licenseExpiry: e.target.value }))}
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="space-y-1 block">
|
|
||||||
<span className="text-sm font-medium text-slate-700">{copy.notes}</span>
|
|
||||||
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} className="input-field min-h-[96px]" placeholder={copy.notesPlaceholder} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="pt-2 flex items-center justify-end gap-3">
|
|
||||||
<button type="button" className="btn-secondary" onClick={() => router.push('/reservations')}>
|
|
||||||
{copy.cancel}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn-primary" onClick={submit} disabled={saving || !canSubmit}>
|
|
||||||
{saving ? copy.creating : copy.create}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 l’espace, 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 n’est marqué Disponible.',
|
||||||
|
availabilityConflict: 'Le véhicule sélectionné n’est 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 d’expiration doit être après la date de délivrance.',
|
||||||
|
expiryFuture: 'La date d’expiration 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: 'L’image 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 }
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 renter’s 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 application’s 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.
|
||||||
Generated
+120
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+254
@@ -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;
|
||||||
Reference in New Issue
Block a user