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
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:
@@ -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é n’est 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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user