fix the booking and contract
Build & Push / Pipeline Tests (push) Failing after 1m31s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 55s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Failing after 46s
Test / API Integration Tests (push) Successful in 1m7s

This commit is contained in:
root
2026-07-27 23:04:26 -04:00
parent 8046a1d447
commit 5649ab02c7
40 changed files with 1699 additions and 1066 deletions
@@ -39,6 +39,7 @@ describe('DashboardAccessGuard route helpers', () => {
expect(resolveAllowedRoutes({ items: [], subscriptionAccessLevel: 'full' }, 'OWNER')).toEqual([
'/',
'/reservations',
'/contracts',
'/fleet',
'/customers',
'/reports',
@@ -59,6 +60,7 @@ describe('DashboardAccessGuard route helpers', () => {
}, 'MANAGER')).toEqual([
'/',
'/reservations',
'/contracts',
'/fleet',
'/customers',
'/reports',
@@ -86,6 +88,7 @@ describe('DashboardAccessGuard route helpers', () => {
}, 'OWNER')).toEqual([
'/',
'/reservations',
'/contracts',
'/fleet',
'/customers',
'/reports',
@@ -94,13 +97,36 @@ describe('DashboardAccessGuard route helpers', () => {
])
})
it('allows contract detail routes whenever reservations are available in a generated menu', () => {
const routes = resolveAllowedRoutes({
subscriptionAccessLevel: 'full',
items: [
{
id: 'dashboard',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/',
children: [],
},
{
id: 'reservations',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/reservations',
children: [],
},
],
}, 'AGENT')
expect(routes).toEqual(['/', '/reservations', '/contracts'])
expect(resolveAccessRedirect('/contracts/reservation_1', routes)).toBeNull()
})
it('does not apply baseline fallback when subscription access is none', () => {
expect(resolveAllowedRoutes({ items: [], subscriptionAccessLevel: 'none' }, 'OWNER')).toEqual([])
})
it('filters baseline routes by role', () => {
expect(getBaselineInternalRoutes('AGENT')).toEqual(['/', '/reservations', '/fleet', '/customers'])
expect(getBaselineInternalRoutes('MANAGER')).toEqual(['/', '/reservations', '/fleet', '/customers', '/reports', '/billing'])
expect(getBaselineInternalRoutes('AGENT')).toEqual(['/', '/reservations', '/contracts', '/fleet', '/customers'])
expect(getBaselineInternalRoutes('MANAGER')).toEqual(['/', '/reservations', '/contracts', '/fleet', '/customers', '/reports', '/billing'])
})
it('redirects disallowed routes to the first visible internal route', () => {
@@ -109,8 +135,8 @@ describe('DashboardAccessGuard route helpers', () => {
})
it('builds sign-in redirects with public dashboard return paths', () => {
expect(buildSignInRedirect('/reservations')).toBe('/dashboard/sign-in?redirect=%2Fdashboard%2Freservations')
expect(buildSignInRedirect('/dashboard/fleet')).toBe('/dashboard/sign-in?redirect=%2Fdashboard%2Ffleet')
expect(buildSignInRedirect('/reservations')).toBe('/en/light/sign-in?redirect=%2Fdashboard%2Freservations')
expect(buildSignInRedirect('/dashboard/fleet')).toBe('/en/light/sign-in?redirect=%2Fdashboard%2Ffleet')
})
it('treats subscription as an owner-only recovery route independent of menu registration', () => {
@@ -3,7 +3,7 @@
import { usePathname, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { toDashboardAppPath, toPublicDashboardPath } from '@/lib/dashboardPaths'
import { buildHomepageSignInPath, toDashboardAppPath, toPublicDashboardPath } from '@/lib/dashboardPaths'
import {
getDashboardFallbackRoute,
resolveDashboardRoutePolicy,
@@ -32,6 +32,7 @@ const ROLE_RANK: Record<string, number> = { OWNER: 3, MANAGER: 2, AGENT: 1 }
const BASELINE_MENU_ROUTES = [
{ route: '/', minRole: 'AGENT' },
{ route: '/reservations', minRole: 'AGENT' },
{ route: '/contracts', minRole: 'AGENT' },
{ route: '/fleet', minRole: 'AGENT' },
{ route: '/customers', minRole: 'AGENT' },
{ route: '/reports', minRole: 'MANAGER' },
@@ -72,7 +73,11 @@ export function resolveAllowedRoutes(menu: EmployeeMenuResponse, role: string):
menu.subscriptionAccessLevel !== 'none' &&
featureRoutes.length === 0
return shouldUseBaselineFallback ? getBaselineInternalRoutes(role) : routes
const resolvedRoutes = shouldUseBaselineFallback ? getBaselineInternalRoutes(role) : routes
if (resolvedRoutes.includes('/reservations') && !resolvedRoutes.includes('/contracts')) {
return [...resolvedRoutes, '/contracts']
}
return resolvedRoutes
}
export function isAllowedRoute(currentPath: string, allowedRoutes: string[]) {
@@ -91,9 +96,7 @@ export function resolveAccessRedirect(currentPath: string, allowedRoutes: string
}
export function buildSignInRedirect(currentPath: string) {
const params = new URLSearchParams()
params.set('redirect', toPublicDashboardPath(currentPath))
return `${toPublicDashboardPath('/sign-in')}?${params.toString()}`
return buildHomepageSignInPath(currentPath)
}
export default function DashboardAccessGuard({ children }: { children: React.ReactNode }) {
@@ -90,6 +90,7 @@ function toSidebarUser(profile: Partial<EmployeeProfile>, fallbackName: string,
const NAV_ITEMS = [
{ href: '/', key: 'dashboard', icon: LayoutDashboard, exact: true, minRole: 'AGENT' },
{ href: '/reservations', key: 'reservations', icon: Calendar, minRole: 'AGENT' },
{ href: '/contracts', key: 'contracts', icon: FileText, minRole: 'AGENT' },
{ href: '/fleet', key: 'fleet', icon: Car, minRole: 'AGENT' },
{ href: '/customers', key: 'customers', icon: Users, minRole: 'AGENT' },
{ href: '/reports', key: 'reports', icon: BarChart2, minRole: 'MANAGER' },
@@ -74,6 +74,8 @@ export function ReservationReview({
rows: [
[copy.deposit, display(draft.payment.depositAmount, copy.summaryEmpty)],
[copy.paymentMode, copy.paymentModes[draft.payment.paymentMode] ?? draft.payment.paymentMode],
[copy.spareWheel, draft.payment.spareWheel ? copy.yes : copy.no],
[copy.radioCd, draft.payment.radioCd ? copy.yes : copy.no],
[copy.additionalDriverInfo, draft.additionalDrivers.length ? String(draft.additionalDrivers.length) : copy.summaryEmpty],
[copy.notes, display(draft.payment.notes, copy.summaryEmpty)],
],
@@ -143,7 +143,7 @@ export function ReservationWizard({
clearFieldError(`rental.${field}`)
}
function updatePayment(field: keyof ReservationDraft['payment'], value: string) {
function updatePayment(field: keyof ReservationDraft['payment'], value: string | boolean) {
dispatch({ type: 'updatePayment', field, value })
clearFieldError(`payment.${field}`)
}
@@ -617,7 +617,7 @@ function PaymentStep({
copy: ReturnType<typeof getReservationWizardCopy>
draft: ReservationDraft
errors: FieldErrors
onPaymentChange: (field: keyof ReservationDraft['payment'], value: string) => void
onPaymentChange: (field: keyof ReservationDraft['payment'], value: string | boolean) => void
onAddDriver: () => void
onRemoveDriver: (id: string) => void
onDriverChange: (id: string, field: keyof AdditionalDriverDraft, value: any) => void
@@ -638,6 +638,26 @@ function PaymentStep({
<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="grid grid-cols-1 gap-4 md:grid-cols-2">
<label className="flex items-center gap-3 rounded-lg border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-blue-600"
checked={draft.payment.spareWheel}
onChange={(event) => onPaymentChange('spareWheel', event.target.checked)}
/>
{copy.spareWheel}
</label>
<label className="flex items-center gap-3 rounded-lg border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-blue-600"
checked={draft.payment.radioCd}
onChange={(event) => onPaymentChange('radioCd', event.target.checked)}
/>
{copy.radioCd}
</label>
</div>
<div className="rounded-lg border border-slate-200 p-4">
<div className="flex items-center justify-between gap-3">
@@ -58,6 +58,10 @@ export function getReservationWizardCopy(language: Language) {
availabilityConflict: 'The selected vehicle is not available for those dates.',
deposit: 'Deposit amount (MAD)',
paymentMode: 'Payment mode',
spareWheel: 'Spare wheel',
radioCd: 'Radio and CD',
yes: 'Yes',
no: 'No',
notes: 'Notes',
notesPlaceholder: 'Optional notes...',
additionalDriverInfo: 'Additional driver',
@@ -141,6 +145,10 @@ export function getReservationWizardCopy(language: Language) {
availabilityConflict: 'Le véhicule sélectionné nest pas disponible pour ces dates.',
deposit: 'Montant du dépôt (MAD)',
paymentMode: 'Mode de paiement',
spareWheel: 'Roue de secours',
radioCd: 'Poste radio et CD',
yes: 'Oui',
no: 'Non',
notes: 'Notes',
notesPlaceholder: 'Notes optionnelles...',
additionalDriverInfo: 'Conducteur supplémentaire',
@@ -224,6 +232,10 @@ export function getReservationWizardCopy(language: Language) {
availabilityConflict: 'المركبة المحددة غير متاحة في هذه التواريخ.',
deposit: 'مبلغ العربون (MAD)',
paymentMode: 'طريقة الدفع',
spareWheel: 'العجلة الاحتياطية',
radioCd: 'الراديو و CD',
yes: 'نعم',
no: 'لا',
notes: 'ملاحظات',
notesPlaceholder: 'ملاحظات اختيارية...',
additionalDriverInfo: 'السائق الإضافي',
@@ -266,4 +278,3 @@ export const wizardSteps: Array<{ id: WizardStepId; labelKey: keyof ReservationW
{ id: 'payment', labelKey: 'payment' },
{ id: 'review', labelKey: 'review' },
]
@@ -61,6 +61,8 @@ export function createInitialDraft(): ReservationDraft {
payment: {
depositAmount: '0',
paymentMode: 'CASH',
spareWheel: false,
radioCd: false,
notes: '',
},
additionalDrivers: [],
@@ -75,7 +77,7 @@ export type ReservationDraftAction =
| { 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: 'updatePayment'; field: keyof ReservationDraft['payment']; value: string | boolean }
| { type: 'addAdditionalDriver' }
| { type: 'removeAdditionalDriver'; id: string }
| { type: 'updateAdditionalDriver'; id: string; field: keyof AdditionalDriverDraft; value: any }
@@ -45,6 +45,8 @@ function draft(): ReservationDraft {
payment: {
depositAmount: '0',
paymentMode: 'CASH',
spareWheel: false,
radioCd: false,
notes: '',
},
additionalDrivers: [],
@@ -66,6 +68,23 @@ describe('submitReservationDraft', () => {
expect(resolvedCustomerIds).toEqual(['customer_1'])
expect(api).toHaveBeenCalledTimes(3)
expect(JSON.parse(String(api.mock.calls[2][1]?.body))).toMatchObject({
contractFields: {
driverFirstName: 'Sara',
driverLastName: 'Alaoui',
driverBirthDate: '01/01/1990',
driverNationality: 'Moroccan',
driverAddress: '12 Main Street',
driverPhone: '+212600000000',
driverCin: 'AB123',
driverLicense: 'DL-123',
driverLicenseIssuedAt: '01/01/2020',
driverLicenseExpiry: '01/01/2099',
vehicleDeparture: '01/07/2026 10:00',
vehicleReturn: '03/07/2026 10:00',
vehicleDuration: '2 days',
},
})
})
it('uses a previously created customer on retry', async () => {
@@ -8,6 +8,29 @@ function isoFromLocal(value: string) {
return new Date(value).toISOString()
}
function formatDateForContract(value: string) {
if (!value) return ''
const [date] = value.split('T')
const [year, month, day] = date.split('-')
return year && month && day ? `${day}/${month}/${year}` : ''
}
function formatDateTimeForContract(value: string) {
if (!value) return ''
const [date, time = ''] = value.split('T')
const [year, month, day] = date.split('-')
const [hour = '00', minute = '00'] = time.split(':')
return year && month && day ? `${day}/${month}/${year} ${hour}:${minute}` : ''
}
function compactFields(fields: Record<string, string>) {
return Object.fromEntries(
Object.entries(fields)
.map(([key, value]) => [key, value.trim()])
.filter(([, value]) => value),
)
}
export async function checkVehicleAvailability(draft: ReservationDraft) {
const { vehicleId, startDate, endDate } = draft.rental
const params = new URLSearchParams({
@@ -68,6 +91,35 @@ function customerPatchPayload(draft: ReservationDraft) {
}
}
function contractFieldsPayload(draft: ReservationDraft) {
const firstAdditionalDriver = draft.additionalDrivers[0]
return compactFields({
driverFirstName: bilingualPrimary(draft.customer.firstName),
driverLastName: bilingualPrimary(draft.customer.lastName),
driverBirthDate: formatDateForContract(draft.identity.dateOfBirth),
driverNationality: draft.identity.nationality,
driverAddress: draft.identity.fullAddress,
driverPhone: draft.customer.phone,
driverCin: draft.identity.identityDocumentNumber,
driverPassport: draft.identity.internationalLicenseNumber,
driverLicense: draft.license.number,
driverLicenseIssuedAt: formatDateForContract(draft.license.issuedAt),
driverLicenseExpiry: formatDateForContract(draft.license.expiry),
secondDriverFirstName: firstAdditionalDriver ? bilingualPrimary(firstAdditionalDriver.firstName) : '',
secondDriverLastName: firstAdditionalDriver ? bilingualPrimary(firstAdditionalDriver.lastName) : '',
secondDriverBirthDate: firstAdditionalDriver?.dateOfBirth ? formatDateForContract(firstAdditionalDriver.dateOfBirth) : '',
secondDriverNationality: firstAdditionalDriver ? bilingualPrimary(firstAdditionalDriver.nationality) : '',
secondDriverPhone: firstAdditionalDriver?.phone ?? '',
secondDriverLicense: firstAdditionalDriver?.driverLicense ?? '',
secondDriverLicenseIssuedAt: firstAdditionalDriver?.licenseIssuedAt ? formatDateForContract(firstAdditionalDriver.licenseIssuedAt) : '',
secondDriverLicenseExpiry: firstAdditionalDriver?.licenseExpiry ? formatDateForContract(firstAdditionalDriver.licenseExpiry) : '',
vehicleDeparture: formatDateTimeForContract(draft.rental.startDate),
vehicleReturn: formatDateTimeForContract(draft.rental.endDate),
vehicleDuration: `${Math.max(1, Math.ceil((new Date(draft.rental.endDate).getTime() - new Date(draft.rental.startDate).getTime()) / (1000 * 60 * 60 * 24)))} days`,
})
}
function reservationPayload(draft: ReservationDraft, customerId: string) {
return {
customerId,
@@ -78,6 +130,9 @@ function reservationPayload(draft: ReservationDraft, customerId: string) {
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,
spareWheel: draft.payment.spareWheel,
radioCd: draft.payment.radioCd,
contractFields: contractFieldsPayload(draft),
additionalDrivers: draft.additionalDrivers.map((driver) => ({
firstName: bilingualPrimary(driver.firstName).trim(),
firstNameAr: driver.firstName.ar.trim() || undefined,
@@ -50,6 +50,8 @@ function validDraft() {
payment: {
depositAmount: '0',
paymentMode: 'CASH',
spareWheel: false,
radioCd: false,
notes: '',
},
additionalDrivers: [],
@@ -78,6 +78,8 @@ export type ReservationDraft = {
payment: {
depositAmount: string
paymentMode: string
spareWheel: boolean
radioCd: boolean
notes: string
}
additionalDrivers: AdditionalDriverDraft[]
@@ -88,4 +90,3 @@ export type WizardStepId = 'customer' | 'identity' | 'license' | 'rental' | 'pay
export type FieldErrors = Record<string, string>
export type CreatedReservation = { id: string }