From 5649ab02c75b86c4d612b047ce9b3d1694a241d9 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 27 Jul 2026 23:04:26 -0400 Subject: [PATCH] fix the booking and contract --- .../reservation.document.service.ts | 46 +- .../reservation.lifecycle.service.test.ts | 17 +- .../reservation.lifecycle.service.ts | 4 +- .../reservations/reservation.presenter.ts | 23 + .../reservations/reservation.schemas.ts | 8 + .../reservations/reservation.service.ts | 35 +- .../modules/reservations/reservation.test.ts | 47 ++ apps/dashboard/README.md | 7 +- .../app/(dashboard)/contracts/[id]/page.tsx | 640 ++++++++++-------- .../(dashboard)/reservations/[id]/page.tsx | 571 +++++++++++++--- .../src/app/(dashboard)/reservations/page.tsx | 18 +- .../src/app/(dashboard)/subscription/page.tsx | 3 +- .../src/app/(public)/sign-in/page.tsx | 22 +- .../ForgotPasswordPageClient.tsx | 9 +- .../src/app/onboarding/accept-invite/page.tsx | 6 +- .../src/app/public-auth-pages.test.ts | 2 +- .../ResetPasswordPageClient.tsx | 8 +- .../[[...sign-in]]/SignInPageClient.tsx | 594 ---------------- .../app/sign-up/[[...sign-up]]/SignUpForm.tsx | 13 +- apps/dashboard/src/app/verify-email/page.tsx | 6 +- .../DashboardAccessGuard.boundary.test.ts | 34 +- .../layout/DashboardAccessGuard.tsx | 13 +- .../src/components/layout/Sidebar.tsx | 1 + .../reservations/new/ReservationReview.tsx | 2 + .../reservations/new/ReservationWizard.tsx | 24 +- .../new/reservationWizard.copy.ts | 13 +- .../new/reservationWizard.reducer.ts | 4 +- .../new/reservationWizard.submit.test.ts | 19 + .../new/reservationWizard.submit.ts | 55 ++ .../new/reservationWizard.test.ts | 2 + .../new/reservationWizard.types.ts | 3 +- apps/dashboard/src/lib/dashboardPaths.test.ts | 8 +- apps/dashboard/src/lib/dashboardPaths.ts | 20 + apps/dashboard/src/middleware.test.ts | 18 +- apps/dashboard/src/middleware.ts | 44 +- docs/contract/generate_rental_contract.py | 267 ++++++++ docs/contract/rental_contract.pdf | Bin 0 -> 136556 bytes docs/contract/rental_contract_preview.html | 154 +++++ docs/create-account-guide.md | 2 +- docs/project-design/PAGES.md | 3 +- 40 files changed, 1699 insertions(+), 1066 deletions(-) delete mode 100644 apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx create mode 100644 docs/contract/generate_rental_contract.py create mode 100644 docs/contract/rental_contract.pdf create mode 100644 docs/contract/rental_contract_preview.html diff --git a/apps/api/src/modules/reservations/reservation.document.service.ts b/apps/api/src/modules/reservations/reservation.document.service.ts index b8d4523..94958dc 100644 --- a/apps/api/src/modules/reservations/reservation.document.service.ts +++ b/apps/api/src/modules/reservations/reservation.document.service.ts @@ -1,5 +1,6 @@ import { prisma } from '../../lib/prisma' import * as repo from './reservation.repo' +import { parseReservationExtras, serializeContractFields } from './reservation.presenter' export function formatDocumentNumber(prefix: string, sequence: number): string { return `${prefix}-${String(sequence).padStart(6, '0')}` @@ -117,6 +118,7 @@ export async function getContract(id: string, companyId: string) { const extras = reservation.extras && typeof reservation.extras === 'object' && !Array.isArray(reservation.extras) ? (reservation.extras as Record) : {} + const contractFields = serializeContractFields(extras.contractFields) const invoiceLineItems = buildReservationInvoiceLineItems({ dailyRate: reservation.dailyRate, @@ -143,6 +145,7 @@ export async function getContract(id: string, companyId: string) { const checkInInspection = reservation.inspections.find((i: any) => i.type === 'CHECKIN') ?? null const checkOutInspection = reservation.inspections.find((i: any) => i.type === 'CHECKOUT') ?? null + const customerAddress = parseReservationExtras(reservation.customer.address) return { reservationId: reservation.id, @@ -153,6 +156,7 @@ export async function getContract(id: string, companyId: string) { status: reservation.status, paymentStatus: reservation.paymentStatus, paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null, + contractFields, notes: reservation.notes, company: { name: reservation.company.brand?.displayName ?? reservation.company.name, @@ -172,6 +176,9 @@ export async function getContract(id: string, companyId: string) { email: reservation.customer.email, phone: reservation.customer.phone, dateOfBirth: reservation.customer.dateOfBirth, + address: typeof customerAddress.fullAddress === 'string' ? customerAddress.fullAddress : null, + identityDocumentNumber: typeof customerAddress.identityDocumentNumber === 'string' ? customerAddress.identityDocumentNumber : null, + internationalLicenseNumber: typeof customerAddress.internationalLicenseNumber === 'string' ? customerAddress.internationalLicenseNumber : null, driverLicense: reservation.customer.driverLicense, licenseCountry: reservation.customer.licenseCountry, licenseCategory: reservation.customer.licenseCategory, @@ -189,6 +196,8 @@ export async function getContract(id: string, companyId: string) { make: reservation.vehicle.make, model: reservation.vehicle.model, year: reservation.vehicle.year, color: reservation.vehicle.color, licensePlate: reservation.vehicle.licensePlate, vin: reservation.vehicle.vin, category: reservation.vehicle.category, + spareWheel: typeof extras.spareWheel === 'boolean' ? extras.spareWheel : false, + radioCd: typeof extras.radioCd === 'boolean' ? extras.radioCd : false, }, rentalPeriod: { startDate: reservation.startDate, endDate: reservation.endDate, totalDays: reservation.totalDays, @@ -229,30 +238,19 @@ export async function getContract(id: string, companyId: string) { export async function getBilling(id: string, companyId: string) { const reservation = await repo.findForBilling(id, companyId) - const baseAmount = reservation.dailyRate * reservation.totalDays - const lineItems = [ - { - description: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model} — ${reservation.totalDays} day(s)`, - qty: reservation.totalDays, unitPrice: reservation.dailyRate, total: baseAmount, category: 'RENTAL', - }, - ...reservation.insurances.map((ins: any) => ({ - description: ins.policyName, - qty: ins.chargeType === 'PER_DAY' ? reservation.totalDays : 1, - unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge, - total: ins.totalCharge, category: 'INSURANCE', - })), - ...reservation.additionalDrivers - .filter((d: any) => d.totalCharge > 0) - .map((d: any) => ({ - description: `Additional Driver — ${d.firstName} ${d.lastName}`, - qty: 1, unitPrice: d.totalCharge, total: d.totalCharge, category: 'ADDITIONAL_DRIVER', - })), - ...(reservation.depositAmount > 0 ? [{ - description: 'Security Deposit (refundable)', - qty: 1, unitPrice: reservation.depositAmount, total: reservation.depositAmount, category: 'DEPOSIT', - }] : []), - ] - const grandTotal = lineItems.reduce((s, i) => s + i.total, 0) - reservation.discountAmount + const lineItems = buildReservationInvoiceLineItems({ + dailyRate: reservation.dailyRate, + totalDays: reservation.totalDays, + discountAmount: reservation.discountAmount, + depositAmount: reservation.depositAmount, + pricingRulesApplied: Array.isArray(reservation.pricingRulesApplied) + ? (reservation.pricingRulesApplied as any) + : [], + insurances: reservation.insurances, + additionalDrivers: reservation.additionalDrivers, + vehicle: reservation.vehicle, + }) + const grandTotal = lineItems.reduce((s, i) => s + i.total, 0) return { lineItems, diff --git a/apps/api/src/modules/reservations/reservation.lifecycle.service.test.ts b/apps/api/src/modules/reservations/reservation.lifecycle.service.test.ts index 2355be6..fe88862 100644 --- a/apps/api/src/modules/reservations/reservation.lifecycle.service.test.ts +++ b/apps/api/src/modules/reservations/reservation.lifecycle.service.test.ts @@ -24,9 +24,18 @@ vi.mock('./reservation.repo', () => ({ findConflict: vi.fn(), })) +vi.mock('./reservation.document.service', () => ({ + ensureReservationDocumentNumbers: vi.fn().mockResolvedValue({ + id: 'reservation_1', + contractNumber: 'CNT-000001', + invoiceNumber: 'INV-000001', + }), +})) + import { AppError } from '../../http/errors' import { prisma } from '../../lib/prisma' import * as repo from './reservation.repo' +import { ensureReservationDocumentNumbers } from './reservation.document.service' import { cancelReservation, checkinReservation, confirmReservation, extendReservation } from './reservation.lifecycle.service' const validLicenseReservation = { @@ -57,8 +66,14 @@ describe('reservation.lifecycle.service', () => { vi.mocked(repo.findCompanyWithBrand).mockResolvedValue({ name: 'Atlas Cars', brand: { displayName: 'Atlas', defaultLocale: 'en' } } as never) vi.mocked(repo.findVehicle).mockResolvedValue({ year: 2024, make: 'Toyota', model: 'Yaris' } as never) - await expect(confirmReservation('reservation_1', 'company_1')).resolves.toEqual({ id: 'reservation_1', status: 'CONFIRMED' }) + await expect(confirmReservation('reservation_1', 'company_1')).resolves.toEqual({ + id: 'reservation_1', + status: 'CONFIRMED', + contractNumber: 'CNT-000001', + invoiceNumber: 'INV-000001', + }) expect(repo.updateById).toHaveBeenCalledWith('reservation_1', { status: 'CONFIRMED' }) + expect(ensureReservationDocumentNumbers).toHaveBeenCalledWith('company_1', 'reservation_1') expect(repo.updateVehicleStatus).toHaveBeenCalledWith('vehicle_1', 'RESERVED') }) diff --git a/apps/api/src/modules/reservations/reservation.lifecycle.service.ts b/apps/api/src/modules/reservations/reservation.lifecycle.service.ts index cfbd2d0..acd8fa3 100644 --- a/apps/api/src/modules/reservations/reservation.lifecycle.service.ts +++ b/apps/api/src/modules/reservations/reservation.lifecycle.service.ts @@ -6,6 +6,7 @@ import { sendNotification, sendTransactionalEmail } from '../../services/notific import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations' import { coerceNotificationLocale } from '../../services/notificationLocalizationService' import { buildBookingRequestProgress, buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter' +import { ensureReservationDocumentNumbers } from './reservation.document.service' import * as repo from './reservation.repo' function buildPickupAddress(value: unknown) { @@ -49,6 +50,7 @@ export async function confirmReservation(id: string, companyId: string) { if (reservation.status !== 'DRAFT') throw new AppError('Only DRAFT reservations can be confirmed', 400, 'invalid_status') const updated = await repo.updateById(id, { status: 'CONFIRMED' }) + const documentNumbers = await ensureReservationDocumentNumbers(companyId, id) await repo.updateVehicleStatus(reservation.vehicleId, 'RESERVED') const [customer, company, vehicle] = await Promise.all([ @@ -134,7 +136,7 @@ export async function confirmReservation(id: string, companyId: string) { } } - return updated + return { ...updated, ...documentNumbers } } export async function checkinReservation(id: string, companyId: string, mileage?: number) { diff --git a/apps/api/src/modules/reservations/reservation.presenter.ts b/apps/api/src/modules/reservations/reservation.presenter.ts index 4fe04a2..2783f51 100644 --- a/apps/api/src/modules/reservations/reservation.presenter.ts +++ b/apps/api/src/modules/reservations/reservation.presenter.ts @@ -10,6 +10,22 @@ export function normalizeOptionalString(value: string | null | undefined): strin return trimmed || null } +const BLOCKED_CONTRACT_FIELD_KEYS = new Set([ + 'companyName', + 'companyAddress', + 'contractCity', + 'deliveryPlace', + 'returnPlace', +]) + +export function serializeContractFields(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return Object.fromEntries( + Object.entries(value) + .filter(([key, fieldValue]) => !BLOCKED_CONTRACT_FIELD_KEYS.has(key) && typeof fieldValue === 'string'), + ) as Record +} + function readAddressField(address: unknown, key: string): string | null { const extras = parseReservationExtras(address) const value = extras[key] @@ -125,10 +141,14 @@ export function serializeReservationForDashboard(reservation: T): T & { paymentMode: string | null + spareWheel: boolean + radioCd: boolean + contractFields: Record workflow: ReturnType bookingRequest: ReturnType } { const extras = parseReservationExtras(reservation.extras) + const contractFields = serializeContractFields(extras.contractFields) const customer = reservation.customer ? { @@ -143,6 +163,9 @@ export function serializeReservationForDashboard | undefined) { + if (!value) return null + const fields: Record = {} + for (const [key, raw] of Object.entries(value)) { + const normalizedKey = key.trim() + const normalizedValue = normalizeOptionalString(raw) + if (normalizedKey && normalizedValue) fields[normalizedKey] = normalizedValue + } + const allowedFields = serializeContractFields(fields) + return Object.keys(allowedFields).length > 0 ? allowedFields : null +} + export async function listReservations(companyId: string, query: { status?: string; vehicleId?: string; source?: string search?: string @@ -61,6 +73,8 @@ export async function createReservation(companyId: string, body: { vehicleId: string; customerId: string; startDate: string; endDate: string pickupLocation?: string; returnLocation?: string; offerId?: string promoCodeUsed?: string; depositAmount?: number; paymentMode?: string; notes?: string + spareWheel?: boolean; radioCd?: boolean + contractFields?: Record selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[] }) { const vehicle = await repo.findVehicle(body.vehicleId, companyId) @@ -93,6 +107,7 @@ export async function createReservation(companyId: string, body: { const baseAmount = vehicle.dailyRate * totalDays const { applied, total: pricingTotal } = await applyPricingRules(companyId, body.customerId, additionalDriversList, vehicle.dailyRate, totalDays) const totalAmount = baseAmount - discountAmount + pricingTotal + depositAmount + const contractFields = normalizeContractFields(body.contractFields) const reservation = await repo.create({ companyId, @@ -111,7 +126,12 @@ export async function createReservation(companyId: string, body: { totalAmount, depositAmount, notes: body.notes ?? null, - extras: body.paymentMode ? { paymentMode: body.paymentMode } : undefined, + extras: { + ...(body.paymentMode ? { paymentMode: body.paymentMode } : {}), + ...(body.spareWheel !== undefined ? { spareWheel: body.spareWheel } : {}), + ...(body.radioCd !== undefined ? { radioCd: body.radioCd } : {}), + ...(contractFields ? { contractFields } : {}), + }, pricingRulesApplied: applied, pricingRulesTotal: pricingTotal, }) @@ -133,6 +153,8 @@ export async function updateReservation(id: string, companyId: string, body: { startDate?: string; endDate?: string; pickupLocation?: string | null returnLocation?: string | null; depositAmount?: number; notes?: string | null paymentMode?: string | null; damageChargeAmount?: number; damageChargeNote?: string | null + spareWheel?: boolean | null; radioCd?: boolean | null + contractFields?: Record }) { const reservation = await repo.findByIdWithRelations(id, companyId) @@ -142,7 +164,7 @@ export async function updateReservation(id: string, companyId: string, body: { const workflow = buildReservationWorkflow(reservation) const requested = Object.keys(body).filter((k) => body[k as keyof typeof body] !== undefined) - const bookingFields = ['startDate', 'endDate', 'pickupLocation', 'returnLocation', 'depositAmount', 'notes', 'paymentMode'] + const bookingFields = ['startDate', 'endDate', 'pickupLocation', 'returnLocation', 'depositAmount', 'notes', 'paymentMode', 'spareWheel', 'radioCd', 'contractFields'] const returnFields = ['returnLocation', 'damageChargeAmount', 'damageChargeNote'] if (!workflow.coreEditable && !workflow.returnEditable) { @@ -193,6 +215,13 @@ export async function updateReservation(id: string, companyId: string, body: { if (next) extras.paymentMode = next else delete extras.paymentMode } + if (body.spareWheel !== undefined) extras.spareWheel = Boolean(body.spareWheel) + if (body.radioCd !== undefined) extras.radioCd = Boolean(body.radioCd) + if (body.contractFields !== undefined) { + const contractFields = normalizeContractFields(body.contractFields) + if (contractFields) extras.contractFields = contractFields + else delete extras.contractFields + } await prisma.$transaction([ prisma.reservation.update({ diff --git a/apps/api/src/modules/reservations/reservation.test.ts b/apps/api/src/modules/reservations/reservation.test.ts index 742fff5..1ea88a1 100644 --- a/apps/api/src/modules/reservations/reservation.test.ts +++ b/apps/api/src/modules/reservations/reservation.test.ts @@ -42,6 +42,14 @@ vi.mock('./reservation.additional-driver.service', () => ({ approveAdditionalDriver: vi.fn(), })) +vi.mock('./reservation.document.service', () => ({ + ensureReservationDocumentNumbers: vi.fn().mockResolvedValue({ + id: 'reservation-1', + contractNumber: 'CNT-000001', + invoiceNumber: 'INV-000001', + }), +})) + vi.mock('../../services/licenseValidationService', () => ({ validateAndFlagLicense: vi.fn().mockResolvedValue(undefined), validateLicense: vi.fn(), @@ -63,6 +71,7 @@ vi.mock('../../lib/prisma', () => ({ vi.mock('./reservation.presenter', () => ({ serializeReservationForDashboard: vi.fn((r) => r), parseReservationExtras: vi.fn(() => ({})), + serializeContractFields: vi.fn((fields) => fields), buildReservationWorkflow: vi.fn(), normalizeOptionalString: vi.fn((s) => s), })) @@ -74,6 +83,7 @@ import * as additionalDriverService from './reservation.additional-driver.servic import { validateLicense } from '../../services/licenseValidationService' import { sendNotification } from '../../services/notificationService' import { buildReservationWorkflow } from './reservation.presenter' +import { ensureReservationDocumentNumbers } from './reservation.document.service' import { createReservation, listReservations } from './reservation.service' import { confirmReservation, checkinReservation, checkoutReservation, closeReservation } from './reservation.lifecycle.service' import { approveAdditionalDriver } from './reservation.additional-driver.service' @@ -177,6 +187,40 @@ describe('createReservation', () => { expect(result.id).toBe(RES_ID) }) + it('stores contract fields from the booking create payload', async () => { + vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle()) + vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any) + vi.mocked(repo.findConflict).mockResolvedValue(null) + vi.mocked(repo.findActiveOffer).mockResolvedValue(null) + vi.mocked(pricingService.applyPricingRules).mockResolvedValue({ applied: [], total: 0 }) + vi.mocked(repo.create).mockResolvedValue(makeReservation() as any) + + await createReservation(COMPANY, { + vehicleId: 'vehicle-1', + customerId: 'customer-1', + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-06-04T00:00:00.000Z', + contractFields: { + driverFirstName: 'Sara', + driverCin: 'AB123', + driverPassport: null, + vehicleDeparture: '01/06/2025 00:00', + }, + selectedInsurancePolicyIds: [], + additionalDrivers: [], + }) + + expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ + extras: expect.objectContaining({ + contractFields: { + driverFirstName: 'Sara', + driverCin: 'AB123', + vehicleDeparture: '01/06/2025 00:00', + }, + }), + })) + }) + it('throws conflict error when vehicle is unavailable', async () => { vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle()) vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any) @@ -216,6 +260,7 @@ describe('confirmReservation', () => { const result = await confirmReservation(RES_ID, COMPANY) expect(repo.updateById).toHaveBeenCalledWith(RES_ID, { status: 'CONFIRMED' }) + expect(ensureReservationDocumentNumbers).toHaveBeenCalledWith(COMPANY, RES_ID) expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ templateKey: 'booking.confirmed', templateVariables: expect.objectContaining({ @@ -231,6 +276,8 @@ describe('confirmReservation', () => { }), })) expect((result as any).status).toBe('CONFIRMED') + expect((result as any).contractNumber).toBe('CNT-000001') + expect((result as any).invoiceNumber).toBe('INV-000001') }) it('rejects non-DRAFT reservation', async () => { diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index a3d9412..a95df6c 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -140,9 +140,9 @@ The sign-in page also supports: Important behavior: -- public dashboard routes are limited to sign-in and forgot-password flows +- public dashboard routes are limited to account creation, password, onboarding, and verification flows - all other `/dashboard/*` routes require the `employee_session` cookie -- unauthenticated users are redirected either to the Carplace root or to `/dashboard/sign-in`, depending on host context +- unauthenticated users are redirected to the homepage sign-in route, `/en/light/sign-in`, with a dashboard return path - duplicate `/dashboard/dashboard` paths are normalized back to `/dashboard` This means route protection is enforced before React renders the protected pages. @@ -618,7 +618,8 @@ Public-facing dashboard pages live outside the private shell. Notable routes: -- `/dashboard/sign-in` +- `/en/light/sign-in` (canonical sign-in) +- `/dashboard/sign-in` (legacy redirect) - `/dashboard/sign-up` - `/dashboard/forgot-password` - `/dashboard/reset-password` diff --git a/apps/dashboard/src/app/(dashboard)/contracts/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/contracts/[id]/page.tsx index 365f788..90b516c 100644 --- a/apps/dashboard/src/app/(dashboard)/contracts/[id]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/contracts/[id]/page.tsx @@ -1,10 +1,9 @@ 'use client' import { useEffect, useMemo, useState } from 'react' -import Image from 'next/image' import Link from 'next/link' import { useParams } from 'next/navigation' -import { formatCurrency, type SupportedCurrency } from '@rentaldrivego/types' +import type { SupportedCurrency } from '@rentaldrivego/types' import { apiFetch } from '@/lib/api' import { useDashboardI18n } from '@/components/I18nProvider' import VehicleConditionSheet from '@/components/reservations/VehicleConditionSheet' @@ -28,6 +27,7 @@ type ContractPayload = { status: string paymentStatus: string paymentMode: string | null + contractFields: Record notes: string | null company: { name: string @@ -47,6 +47,9 @@ type ContractPayload = { email: string phone: string | null dateOfBirth: string | null + address: string | null + identityDocumentNumber: string | null + internationalLicenseNumber: string | null driverLicense: string | null licenseCountry: string | null licenseCategory: string | null @@ -75,6 +78,8 @@ type ContractPayload = { licensePlate: string vin: string | null category: string + spareWheel: boolean + radioCd: boolean } rentalPeriod: { startDate: string @@ -180,6 +185,14 @@ function splitTerms(text: string | null | undefined) { .filter(Boolean) } +function compactText(value: string | null | undefined, fallback: string, maxLength = 220) { + const normalized = (value ?? '').replace(/\s+/g, ' ').trim() + if (!normalized) return fallback + if (normalized.length <= maxLength) return normalized + const clipped = normalized.slice(0, maxLength).trimEnd().replace(/[,\s.;:]+$/, '') + return `${clipped}...` +} + type ConditionLanguage = 'en' | 'fr' | 'ar' type ConditionItem = { @@ -350,8 +363,10 @@ function normalizeContractLocale(value: string | null | undefined, fallback: Con } function getConditionLanguages(mode: 'en' | 'en-ar' | 'fr' | 'fr-ar' | 'ar'): ConditionLanguage[] { - if (mode === 'en' || mode === 'en-ar') return ['en', 'ar'] - if (mode === 'fr' || mode === 'fr-ar') return ['fr', 'ar'] + if (mode === 'en-ar') return ['en', 'ar'] + if (mode === 'fr-ar') return ['fr', 'ar'] + if (mode === 'en') return ['en'] + if (mode === 'fr') return ['fr'] return ['ar'] } @@ -372,46 +387,6 @@ function splitConditionItems(items: ConditionItem[]) { return [items.slice(0, midpoint), items.slice(midpoint)] } -function PaperField({ - label, - value, - className = '', -}: { - label: string - value: string - className?: string -}) { - return ( -
-
- {label} -
-
- {value} -
-
- ) -} - -function PaperSection({ - title, - children, - className = '', -}: { - title: string - children: React.ReactNode - className?: string -}) { - return ( -
-
- {title} -
-
{children}
-
- ) -} - export default function ContractDetailPage() { const { id } = useParams<{ id: string }>() const { language } = useDashboardI18n() @@ -470,7 +445,7 @@ export default function ContractDetailPage() { loading: 'Loading contract…', yes: 'Yes', no: 'No', - agreementTitle: 'Rental Agreement', + agreementTitle: 'Rental contract', agreementForReservation: 'For reservation', draftAgreement: 'Draft agreement', customerInformation: 'Customer information', @@ -840,7 +815,7 @@ export default function ContractDetailPage() { }) : copy.none - const address = useMemo(() => ( + const companyAddressFormatted = useMemo(() => ( contract ? formatAddress(contract.company.address, contract.company.city, contract.company.country) : '' ), [contract]) @@ -851,41 +826,135 @@ export default function ContractDetailPage() { const damagePoints = contract.inspections.checkIn?.damagePoints ?? [] const customClauses = splitTerms(contract.terms.terms) - const contractLocaleMode = normalizeContractLocale(language, language) - const conditionLanguages = getConditionLanguages(contractLocaleMode) - const conditionColumns = conditionLanguages - .map((conditionLanguage) => ({ - language: conditionLanguage, - heading: getConditionLanguageLabel(conditionLanguage), - clauses: [ - ...FIXED_CONDITIONS[conditionLanguage], - ...customClauses.map((clause, index) => ({ - title: getAdditionalConditionTitle(conditionLanguage, index), - body: clause, - })), - ], - })) - .sort((a, b) => (a.language === 'ar' ? 1 : 0) - (b.language === 'ar' ? 1 : 0)) - const singleLanguageClauseColumns = conditionColumns.length === 1 - ? splitConditionItems(conditionColumns[0].clauses) - : [] - const isBilingual = conditionColumns.length === 2 + const visibleConditionColumns = (['fr', 'ar'] as ConditionLanguage[]).map((conditionLanguage) => ({ + language: conditionLanguage, + heading: getConditionLanguageLabel(conditionLanguage), + clauses: [ + ...FIXED_CONDITIONS[conditionLanguage], + ...customClauses.map((clause, index) => ({ + title: getAdditionalConditionTitle(conditionLanguage, index), + body: clause, + })), + ], + })) const arCopy = copyByLanguage.ar - const bl = (primary: string, ar: string) => isBilingual ? `${primary} / ${ar}` : primary + const frCopy = copyByLanguage.fr + const localizedRentalPolicyTemplates = { + fuel: { + fr: 'Le vehicule est fourni avec le niveau de carburant ou le pourcentage de charge de batterie electrique indique sur le contrat de location. Le locataire doit restituer le vehicule avec le meme niveau de carburant ou, pour les vehicules electriques, le meme pourcentage de charge de batterie releve a la prise en charge.\n\nSi le vehicule est restitue avec moins de carburant ou un pourcentage de charge electrique inferieur, le carburant manquant ou l ecart de charge sera facture au tarif de ravitaillement ou de recharge applicable, avec les frais de service configures le cas echeant. Les frais sont calcules selon la jauge de carburant ou le pourcentage de batterie du vehicule et les niveaux releves au debut et a la fin de la location.\n\nLe carburant ou la recharge achete pendant la location reste a la charge du locataire et n est pas remboursable. Le locataire doit conserver les recus de carburant ou de recharge jusqu a la restitution du vehicule et la cloture du contrat de location.\n\nLe locataire doit utiliser le type de carburant ou la methode de recharge corrects indiques pour le vehicule. Tout dommage cause par l utilisation d un mauvais carburant, chargeur, connecteur ou mode de recharge sera facture au locataire.', + ar: 'يتم تسليم المركبة بمستوى الوقود أو نسبة شحن البطارية الكهربائية المسجلة في عقد الإيجار. يجب على المستأجر إعادة المركبة بنفس مستوى الوقود أو، بالنسبة للمركبات الكهربائية، بنفس نسبة شحن البطارية المسجلة عند الاستلام.\n\nإذا أعيدت المركبة بمستوى وقود أقل أو بنسبة شحن بطارية كهربائية أقل، يتحمل المستأجر تكلفة الوقود الناقص أو فرق الشحن وفق سعر التزويد أو الشحن المعتمد، إضافة إلى أي رسوم خدمة محددة. تعتمد الرسوم على عداد الوقود أو نسبة شحن البطارية والمستويات المسجلة عند بداية ونهاية الإيجار.\n\nالوقود أو الشحن الذي يتم شراؤه أثناء فترة الإيجار يبقى على مسؤولية المستأجر ولا يكون قابلا للاسترداد. يجب على المستأجر الاحتفاظ بإيصالات الوقود أو الشحن إلى حين إعادة المركبة وإغلاق عقد الإيجار.\n\nيجب على المستأجر استخدام نوع الوقود أو طريقة الشحن الصحيحة المحددة للمركبة. أي ضرر ناتج عن استخدام وقود أو شاحن أو موصل أو طريقة شحن غير صحيحة يتم تحميله على المستأجر.', + }, + deposit: { + fr: "Le depot de garantie est remboursable dans un delai de 7 jours apres la restitution, sous reserve de l'inspection du vehicule.", + ar: 'العربون قابل للاسترداد خلال 7 أيام بعد إرجاع المركبة، شريطة فحص المركبة.', + }, + lateFees: { + fr: 'Les retours tardifs entrainent une facturation d une journee de location supplementaire par heure de retard.', + ar: 'كل تأخير في إرجاع المركبة يترتب عنه احتساب يوم كراء إضافي عن كل ساعة تأخير.', + }, + damage: { + fr: 'Le locataire est responsable de l inspection du vehicule lors de la prise en charge et doit confirmer que tous les dommages existants sont indiques sur le contrat de location ou le rapport d etat du vehicule. Tout dommage non enregistre doit etre signale avant que le vehicule ne soit conduit.\n\nLe vehicule doit etre restitue dans le meme etat que celui dans lequel il a ete fourni, sous reserve de l usure raisonnable liee a une utilisation normale.\n\nLe locataire peut etre responsable des nouveaux dommages de carrosserie, peinture, vitrage, pneus, roues ou interieur; des dommages causes par collision, mauvaise utilisation, negligence ou conduite inappropriee; des dommages resultant d une conduite sur routes inadaptees ou dans des zones interdites; des dommages causes par un conducteur non autorise; des dommages causes par un mauvais carburant, la perte des cles ou le defaut de securisation du vehicule; ainsi que des frais de remorquage, recuperation, evaluation, reparation, administration et immobilisation le cas echeant.\n\nToute franchise, garantie ou protection est soumise a ses exclusions, limites et franchises indiquees. Elle ne supprime pas automatiquement toute responsabilite financiere.\n\nLe locataire doit signaler tout accident, vol, vandalisme ou dommage des que raisonnablement possible et suivre les instructions de declaration de la societe. Lorsque cela est requis, le locataire doit egalement contacter la police et obtenir un rapport officiel ou un numero de reference.\n\nLe locataire ne doit pas organiser ni autoriser des reparations sans accord ecrit prealable de la societe de location, sauf si cela est necessaire pour proteger la securite des personnes ou empecher un dommage immediat supplementaire.\n\nLes frais de dommage seront justifies par les elements disponibles, notamment rapports d inspection, photos, devis, factures ou autres preuves raisonnables. Tout depot de garantie peut etre retenu ou impute sur les frais valables prevus au contrat de location.', + ar: 'يتحمل المستأجر مسؤولية فحص المركبة عند الاستلام والتأكد من تسجيل جميع الأضرار الموجودة في عقد الإيجار أو تقرير حالة المركبة. يجب الإبلاغ عن أي ضرر غير مسجل قبل قيادة المركبة.\n\nيجب إعادة المركبة بنفس الحالة التي سلمت بها، مع السماح بالاستهلاك المعقول الناتج عن الاستخدام العادي.\n\nقد يتحمل المستأجر مسؤولية الأضرار الجديدة في الهيكل أو الطلاء أو الزجاج أو الإطارات أو العجلات أو المقصورة الداخلية؛ والأضرار الناتجة عن التصادم أو سوء الاستخدام أو الإهمال أو القيادة غير السليمة؛ والأضرار الناتجة عن القيادة في طرق غير مناسبة أو مناطق محظورة؛ والأضرار التي يسببها سائق غير مصرح له؛ والأضرار الناتجة عن استخدام وقود غير صحيح أو فقدان المفاتيح أو عدم تأمين المركبة؛ ورسوم السحب والاسترجاع والتقييم والإصلاح والإدارة وفقدان الاستخدام متى كانت مطبقة.\n\nأي إعفاء من الضرر أو باقة حماية يخضع للاستثناءات والحدود ومبلغ التحمل المحدد له. ولا يلغي ذلك تلقائيا جميع المسؤوليات المالية.\n\nيجب على المستأجر الإبلاغ عن أي حادث أو سرقة أو تخريب أو ضرر في أقرب وقت ممكن واتباع تعليمات الشركة الخاصة بالإبلاغ. وعند الاقتضاء، يجب على المستأجر أيضا التواصل مع الشرطة والحصول على تقرير رسمي أو رقم مرجعي.\n\nلا يجوز للمستأجر ترتيب أو السماح بأي إصلاحات دون موافقة كتابية مسبقة من شركة التأجير، إلا عندما يكون ذلك ضروريا لحماية السلامة الشخصية أو منع ضرر فوري إضافي.\n\nتدعم رسوم الأضرار بسجلات الفحص والصور وتقديرات الإصلاح والفواتير أو أي أدلة معقولة أخرى متاحة. يجوز حجز مبلغ الضمان أو استخدامه لتغطية الرسوم الصحيحة بموجب عقد الإيجار.', + }, + additionalDriver: { + fr: 'Seuls les conducteurs inscrits et approuves sur le contrat de location sont autorises a conduire le vehicule.\n\nCette politique conducteur s applique au locataire principal et a chaque conducteur supplementaire. Chaque conducteur doit respecter l age minimum requis, presenter un permis de conduire valide accepte sur le lieu de location, fournir toute piece d identification supplementaire raisonnablement demandee et respecter toutes les conditions du contrat de location.\n\nLes conducteurs ages de 25 ans ou plus beneficient du tarif normal. Les conducteurs ages de 18 a 24 ans peuvent etre acceptes avec des frais supplementaires jeune conducteur. Ces frais peuvent s appliquer au locataire principal ou a tout conducteur supplementaire dans cette tranche d age, selon le mode de facturation et les regles de tarification selectionnes.\n\nDes frais de conducteur supplementaire peuvent s appliquer par jour ou par location selon le mode de facturation selectionne. Les frais et les taxes applicables seront confirmes avant le debut de la location.\n\nLe locataire principal reste responsable du vehicule ainsi que de tous les frais, dommages, amendes, penalites et manquements au contrat de location, quel que soit le conducteur approuve qui utilisait le vehicule.\n\nAutoriser une personne non approuvee a conduire le vehicule constitue une violation du contrat de location et peut annuler toute franchise, protection ou couverture d assurance.', + ar: 'يسمح بقيادة المركبة فقط للسائقين المدرجين والمعتمدين في عقد الإيجار.\n\nتنطبق سياسة السائق هذه على المستأجر الرئيسي وكل سائق إضافي. يجب على كل سائق استيفاء شرط السن الأدنى، وتقديم رخصة قيادة صالحة ومقبولة في موقع الإيجار، وتقديم أي وثائق تعريف إضافية مطلوبة بشكل معقول، والالتزام بجميع شروط عقد الإيجار.\n\nالسائقون بعمر 25 سنة أو أكثر يخضعون للسعر العادي. السائقون من 18 إلى 24 سنة يمكن قبولهم مع تطبيق رسوم إضافية للسائق الشاب. قد تطبق هذه الرسوم على المستأجر الرئيسي أو على أي سائق إضافي ضمن هذه الفئة العمرية، حسب إعداد الرسوم وقواعد التسعير المحددة.\n\nقد تطبق رسوم السائق الإضافي يوميا أو لكل إيجار حسب إعداد الرسوم المحدد. يتم تأكيد الرسوم وأي ضرائب مطبقة قبل بداية الإيجار.\n\nيبقى المستأجر الرئيسي مسؤولا عن المركبة وعن جميع الرسوم والأضرار والغرامات والمخالفات وأي إخلال بعقد الإيجار، بغض النظر عن السائق المعتمد الذي كان يقود المركبة.\n\nالسماح لشخص غير مصرح له بقيادة المركبة يعد إخلالا بعقد الإيجار وقد يؤدي إلى إبطال أي إعفاء من الضرر أو باقة حماية أو تغطية تأمينية.', + }, + } as const + const isKnownDefaultPolicy = (value: string | null | undefined, key: keyof typeof localizedRentalPolicyTemplates) => { + const text = value?.trim() + if (!text) return true + if (key === 'fuel') return /fuel level|niveau de carburant|مستوى الوقود/.test(text) + if (key === 'deposit') return /security deposit|depot de garantie|dépôt de garantie|العربون/.test(text) + if (key === 'lateFees') return /Late returns|retours tardifs|retour en retard|تأخير/.test(text) + if (key === 'damage') return /existing damage|dommages existants|الأضرار الموجودة/.test(text) + return /drivers listed|conducteurs inscrits|conducteurs mentionnés|السائقين/.test(text) + } + const policyValue = (value: string | null | undefined, key: keyof typeof localizedRentalPolicyTemplates, policyLanguage: 'fr' | 'ar') => + isKnownDefaultPolicy(value, key) ? localizedRentalPolicyTemplates[key][policyLanguage] : value + const rentalPolicyItems = [ + { + key: 'fuel', + frLabel: frCopy.fuel, + arLabel: arCopy.fuel, + frValue: policyValue(contract.terms.fuelPolicy, 'fuel', 'fr'), + arValue: policyValue(contract.terms.fuelPolicy, 'fuel', 'ar'), + }, + { + key: 'deposit', + frLabel: frCopy.deposit, + arLabel: arCopy.deposit, + frValue: policyValue(contract.terms.depositPolicy, 'deposit', 'fr'), + arValue: policyValue(contract.terms.depositPolicy, 'deposit', 'ar'), + }, + { + key: 'late-fees', + frLabel: frCopy.lateFees, + arLabel: arCopy.lateFees, + frValue: policyValue(contract.terms.lateFeePolicy, 'lateFees', 'fr'), + arValue: policyValue(contract.terms.lateFeePolicy, 'lateFees', 'ar'), + }, + { + key: 'damage', + frLabel: frCopy.damage, + arLabel: arCopy.damage, + frValue: policyValue(contract.terms.damagePolicy, 'damage', 'fr'), + arValue: policyValue(contract.terms.damagePolicy, 'damage', 'ar'), + }, + { + key: 'additional-driver', + frLabel: frCopy.additionalDriverPolicy, + arLabel: arCopy.additionalDriverPolicy, + frValue: policyValue(contract.terms.additionalDriverPolicy, 'additionalDriver', 'fr'), + arValue: policyValue(contract.terms.additionalDriverPolicy, 'additionalDriver', 'ar'), + }, + ] - const roadsidePhone = contract.company.phone ?? copy.none - const latestPayment = contract.invoice.payments.at(-1) - const driverFullName = `${contract.driver.firstName} ${contract.driver.lastName}` - const additionalDriverNames = contract.additionalDrivers.length > 0 - ? contract.additionalDrivers.map((driver) => `${driver.firstName} ${driver.lastName}`).join(', ') - : copy.noAdditionalDrivers + const contractField = (key: string, fallback: string | null | undefined) => { + const override = contract.contractFields?.[key]?.trim() + return override || fallback || copy.none + } + const contractFieldRaw = (key: string) => contract.contractFields?.[key]?.trim() || '' + const driverFullName = `${contractField('driverFirstName', contract.driver.firstName)} ${contractField('driverLastName', contract.driver.lastName)}` + const secondDriver = contract.additionalDrivers[0] + const hasSecondDriverOverride = [ + 'secondDriverFirstName', + 'secondDriverLastName', + 'secondDriverBirthDate', + 'secondDriverNationality', + 'secondDriverAddress', + 'secondDriverPhone', + 'secondDriverCin', + 'secondDriverPassport', + 'secondDriverLicense', + 'secondDriverLicenseIssuedAt', + 'secondDriverLicenseExpiry', + ].some((key) => Boolean(contractFieldRaw(key))) + const hasSecondDriver = Boolean(secondDriver || hasSecondDriverOverride) + const companyName = contract.company.name || copy.none + const companyAddress = companyAddressFormatted || copy.none + const contractCity = contract.company.city || '' + const companyLogoSrc = contract.company.logoUrl ?? '/rentaldrivego.png' + const checkInFuelLabel = contractField('vehicleFuelLevel', tl(copy.fuelLevelLabels, contract.inspections.checkIn?.fuelLevel ?? '')) + const fuelOptions = [ + { value: 'EMPTY', label: '0' }, + { value: 'QUARTER', label: '1/4' }, + { value: 'HALF', label: '1/2' }, + { value: 'THREE_QUARTERS', label: '3/4' }, + { value: 'FULL', label: '1' }, + ] + const isFuelSelected = (value: string, label: string) => { + const override = contractFieldRaw('vehicleFuelLevel') + return override ? override === value || override === label : contract.inspections.checkIn?.fuelLevel === value + } + const checkbox = (checked: boolean) => checked ? '☒' : '☐' return (
{copy.back} -

{contract.company.name}

+

{companyName}

{contract.contractNumber ?? copy.contractNo}

{tl(copy.contractStatusLabels, contract.status)} @@ -906,235 +975,256 @@ export default function ContractDetailPage() {
-
-
-
- {contract.company.logoUrl && ( -
- {contract.company.name} -
- )} -
-

- {contract.company.name} -

-

- {contract.company.name} {bl(copy.agreementTitle, arCopy.agreementTitle)} -

-

- {bl(copy.agreementForReservation, arCopy.agreementForReservation)} {contract.contractNumber ?? contract.reservationId.slice(-8).toUpperCase()} -

+
+
+
+ {companyName} +
+ {companyName}
-
-
-

{bl(copy.status, arCopy.status)}

-

{tl(copy.contractStatusLabels, contract.status)}

-
-
-

{bl(copy.paymentStatus, arCopy.paymentStatus)}

-

{tl(copy.paymentStatusLabels, contract.paymentStatus)}

-
-
-

{bl(copy.contractNo, arCopy.contractNo)}

-

{contract.contractNumber ?? '—'}

-
-
-

{bl(copy.generated, arCopy.generated)}

-

{formatDateTime(contract.generatedAt)}

-
+
+
+

+ Contrat de location de voiture + عقد كراء سيارة +

+
+
+ +
+
+
Date de départ
+
+ {[contractCity, formatDateTime(contract.rentalPeriod.startDate)].filter(Boolean).join(' le ')} +
+
+
+
N° Contrat
+
+ {contract.contractNumber ?? copy.draftAgreement}
-
-
- -
- - - - - - - - - -
-
+
+
Lieu de livraison : {contract.rentalPeriod.pickupLocation ?? contract.company.city ?? copy.none}
+
Lieu de reprise : {contract.rentalPeriod.returnLocation ?? contract.company.city ?? copy.none}
+
- -
- - - - - - - -
-
- - -
- 0 ? contract.insurance.policies.map((policy) => `${policy.name} (${tl(copy.chargeTypeLabels, policy.chargeType)})`).join(', ') : copy.noInsurance} - className="bg-white" - /> - - - - -
-
- - -
-

{copy.signatureAcknowledgement}

- {isBilingual && ( -

{arCopy.signatureAcknowledgement}

- )} -

- {bl(copy.signature, arCopy.signature)}: {contract.terms.signatureRequired ? copy.yes : copy.no} -

-
-
- {bl(copy.customerSignature, arCopy.customerSignature)} -
-
- {bl(copy.companyRepresentative, arCopy.companyRepresentative)} -
-
-
-
-
- -
- -
- - - - - -
-

{bl(copy.damageDiagram, arCopy.damageDiagram)}

-
- -
-

- {damagePoints.length > 0 ? copy.damageMarkersRecorded(damagePoints.length) : copy.noDamage} -

-
-
-
- - -
-
- - - - - -
-
-

{bl(copy.chargesAndFees, arCopy.chargesAndFees)}

-
- {contract.invoice.lineItems.map((item, index) => ( -
-
-

{item.description}

-

{copy.qty} {item.qty} · {formatCurrency(item.unitPrice, contract.invoice.currency)}

-
-

{formatCurrency(item.total, contract.invoice.currency)}

+
+
+
+
Conducteur I
+
+ {[ + ['Prénom:', 'الاسم', contractField('driverFirstName', contract.driver.firstName)], + ['Nom:', 'النسب', contractField('driverLastName', contract.driver.lastName)], + ['Date de naissance:', 'تاريخ الازدياد', contractField('driverBirthDate', formatDateOnly(contract.driver.dateOfBirth, localeCode, copy.none))], + ['Nationalité:', 'الجنسية', contractField('driverNationality', contract.driver.nationality)], + ['Adresse:', 'العنوان', contractField('driverAddress', contract.driver.address)], + ['N° téléphone:', 'رقم الهاتف', contractField('driverPhone', contract.driver.phone)], + ['N° de C.I.N.:', 'رقم البطاقة الوطنية', contractField('driverCin', contract.driver.identityDocumentNumber)], + ['N° de Passeport:', 'رقم جواز السفر', contractField('driverPassport', contract.driver.internationalLicenseNumber)], + ['N° de Permis:', 'رقم رخصة السياقة', contractField('driverLicense', contract.driver.driverLicense)], + ['Délivrée le:', 'سلمت بتاريخ', contractField('driverLicenseIssuedAt', formatDateOnly(contract.driver.licenseIssuedAt, localeCode, copy.none))], + ['Expire le:', 'تنتهي بتاريخ', contractField('driverLicenseExpiry', formatDateOnly(contract.driver.licenseExpiry, localeCode, copy.none))], + ].map(([label, arLabel, value]) => ( +
+ {label} + {value} + {arLabel}
))} -
-
- {bl(copy.subtotal, arCopy.subtotal)} - {formatCurrency(contract.invoice.subtotal, contract.invoice.currency)} -
-
- {bl(copy.taxesLabel, arCopy.taxesLabel)} - - {contract.invoice.taxes.length > 0 - ? formatCurrency(contract.invoice.taxTotal, contract.invoice.currency) - : copy.none} - -
-
- {bl(copy.totalChargeLabel, arCopy.totalChargeLabel)} - {formatCurrency(contract.invoice.total, contract.invoice.currency)} -
-
- {bl(copy.amountPaidLabel, arCopy.amountPaidLabel)} - {formatCurrency(contract.invoice.amountPaid, contract.invoice.currency)} -
-
- {bl(copy.balanceDueLabel, arCopy.balanceDueLabel)} - {formatCurrency(contract.invoice.balanceDue, contract.invoice.currency)} -
-
- {bl(copy.paymentMode, arCopy.paymentMode)} - {contract.paymentMode ?? latestPayment?.paymentMethod ?? copy.none} -
-
- {latestPayment ? ( -
- {bl(copy.lastPayment, arCopy.lastPayment)}: {tl(copy.paymentProviderLabels, latestPayment.provider)} · {tl(copy.paymentStatusLabels, latestPayment.status)} · {formatDateTime(latestPayment.paidAt ?? latestPayment.createdAt)} -
- ) : null}
- +
+ +
+
Conducteur II
+ {hasSecondDriver ? ( +
+ {[ + ['Prénom:', 'الاسم', contractField('secondDriverFirstName', secondDriver?.firstName)], + ['Nom:', 'النسب', contractField('secondDriverLastName', secondDriver?.lastName)], + ['Date de naissance:', 'تاريخ الازدياد', contractField('secondDriverBirthDate', formatDateOnly(secondDriver?.dateOfBirth, localeCode, copy.none))], + ['Nationalité:', 'الجنسية', contractField('secondDriverNationality', secondDriver?.licenseCountry)], + ['Adresse:', 'العنوان', contractField('secondDriverAddress', '')], + ['N° téléphone:', 'رقم الهاتف', contractField('secondDriverPhone', secondDriver?.phone)], + ['N° de C.I.N.:', 'رقم البطاقة الوطنية', contractField('secondDriverCin', '')], + ['N° de Passeport:', 'رقم جواز السفر', contractField('secondDriverPassport', '')], + ['N° de Permis:', 'رقم رخصة السياقة', contractField('secondDriverLicense', secondDriver?.driverLicense)], + ['Délivrée le:', 'سلمت بتاريخ', contractField('secondDriverLicenseIssuedAt', formatDateOnly(secondDriver?.licenseIssuedAt, localeCode, copy.none))], + ['Expire le:', 'تنتهي بتاريخ', contractField('secondDriverLicenseExpiry', formatDateOnly(secondDriver?.licenseExpiry, localeCode, copy.none))], + ].map(([label, arLabel, value]) => ( +
+ {label} + {value} + {arLabel} +
+ ))} +
+ ) : ( +
+ Prénom: *****    AUCUN    ***** لا أحد
+ Nom: *****
+ Date de naissance: *****
+ N° de C.I.N.: *****
+ N° de Permis: *****
+ Délivrée le: ***** +
+ )} +
+ +
+ Le client est seul responsable des délits, contraventions et infractions au code de la route. +
+
+ +
+
+
Véhicule
+
+ {[ + ['Marque :', contractField('vehicleMakeModel', `${contract.vehicle.make} ${contract.vehicle.model}`)], + ['Matricule :', contractField('vehicleRegistration', contract.vehicle.licensePlate)], + ['Date de départ :', contractField('vehicleDeparture', formatDateTime(contract.rentalPeriod.startDate))], + ['Date de retour :', contractField('vehicleReturn', formatDateTime(contract.rentalPeriod.endDate))], + ['Durée de location :', contractField('vehicleDuration', `${contract.rentalPeriod.totalDays} jour(s)`)], + ['Carburant :', contractField('vehicleFuelType', checkInFuelLabel)], + ['Prolongation :', `${checkbox(false)} OUI ${checkbox(true)} NON`], + ['Retour en cas de prolongation :', contractField('vehicleExtensionReturn', '')], + ].map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+ +
+
+
Roue de secours
+
+ {checkbox(contract.vehicle.spareWheel)} OUI   {checkbox(!contract.vehicle.spareWheel)} NON +
+
+
+
Poste radio et CD
+
+ {checkbox(contract.vehicle.radioCd)} OUI   {checkbox(!contract.vehicle.radioCd)} NON +
+
+
+ +
+
Niveau carburant
+
+ {fuelOptions.map((option) => ( +
+ {option.label} + {isFuelSelected(option.value, option.label) ? 'X' : ''} +
+ ))} +
+
+ +
+
État général à la réception du client
+
+ +
+

+ {damagePoints.length > 0 ? copy.damageMarkersRecorded(damagePoints.length) : copy.noDamage} +

+
-
- {bl(copy.roadsideAssistance, arCopy.roadsideAssistance)} {roadsidePhone} -
+
+
+ Signature du client : lu et approuvé des conditions +
+
+ Cachet et signature de la société +
+
+ +
+ Adresse : {companyAddress} +
+ Contact : {[contract.company.phone, contract.company.email].filter(Boolean).join(' · ') || copy.none} +
+
-
-
-

{bl(copy.termsAndConditions, arCopy.termsAndConditions)}

-

- {contract.company.name} · {contract.contractNumber ?? copy.draftAgreement} -

+
+
+
+ {companyName} +

{companyName}

+
+
+

+ {frCopy.termsAndConditions} + {arCopy.termsAndConditions} +

+
+
- {conditionColumns.length === 2 ? ( -
- {conditionColumns.map((column) => ( -
-
-

{column.heading}

+
+
+
+

Politiques de location

+
+ {rentalPolicyItems.map((policy) => ( +
+

{policy.frLabel}

+

{policy.frValue || frCopy.none}

+
+ ))} +
+
+
+

سياسات الكراء

+
+ {rentalPolicyItems.map((policy) => ( +
+

{policy.arLabel}

+

{policy.arValue || arCopy.none}

+
+ ))} +
+
+
+
+ + {visibleConditionColumns.length === 2 ? ( +
+ {visibleConditionColumns.map((column) => ( +
+
+

{column.heading}

{column.clauses.map((clause, index) => ( -
+

{clause.title}

-

{clause.body}

+

{compactText(clause.body, copy.none, 520)}

))}
))}
) : ( -
- {singleLanguageClauseColumns.map((columnClauses, columnIndex) => ( -
+
+ {splitConditionItems(visibleConditionColumns[0].clauses).flatMap((columnClauses, index) => + index === 0 ? [columnClauses.slice(0, Math.ceil(columnClauses.length / 2)), columnClauses.slice(Math.ceil(columnClauses.length / 2))] : [columnClauses], + ).map((columnClauses, columnIndex) => ( +
{columnClauses.map((clause, index) => ( -
+

{clause.title}

-

{clause.body}

+

{compactText(clause.body, copy.none, 620)}

))}
diff --git a/apps/dashboard/src/app/(dashboard)/reservations/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/reservations/[id]/page.tsx index 663dfa2..26e4cbe 100644 --- a/apps/dashboard/src/app/(dashboard)/reservations/[id]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/reservations/[id]/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useState } from 'react' +import Link from 'next/link' import { useParams } from 'next/navigation' import { formatCurrency } from '@rentaldrivego/types' import { apiFetch } from '@/lib/api' @@ -14,6 +15,7 @@ interface ReservationDetail { source: string startDate: string endDate: string + totalDays: number totalAmount: number discountAmount: number insuranceTotal: number @@ -24,6 +26,9 @@ interface ReservationDetail { pickupLocation: string | null returnLocation: string | null paymentMode: string | null + spareWheel: boolean + radioCd: boolean + contractFields: Record notes: string | null contractNumber: string | null invoiceNumber: string | null @@ -35,15 +40,22 @@ interface ReservationDetail { lastName: string email: string phone: string | null + dateOfBirth?: string | null + nationality?: string | null + address?: Record | null driverLicense: string | null + licenseIssuedAt?: string | null + licenseExpiry?: string | null licenseImageUrl: string | null licenseValidationStatus: string flagged: boolean } vehicle: { + year?: number | null make: string model: string licensePlate: string + fuelType?: string | null } insurances: { id: string; policyName: string; totalCharge: number }[] additionalDrivers: { @@ -51,6 +63,11 @@ interface ReservationDetail { firstName: string lastName: string driverLicense: string + phone?: string | null + dateOfBirth?: string | null + nationality?: string | null + licenseIssuedAt?: string | null + licenseExpiry?: string | null totalCharge: number requiresApproval: boolean approvedAt: string | null @@ -75,6 +92,20 @@ interface ReservationDetail { } } +interface ReservationBilling { + lineItems: { + description: string + qty: number + unitPrice: number + total: number + category: string + }[] + discountAmount: number + pricingRulesApplied: { name: string; amount: number; type: string }[] | null + pricingRulesTotal: number + grandTotal: number +} + type EditMode = 'booking' | 'return' | null type ReservationFormState = { @@ -84,11 +115,62 @@ type ReservationFormState = { returnLocation: string depositAmount: string paymentMode: string + spareWheel: boolean + radioCd: boolean + contractFields: Record notes: string damageChargeAmount: string damageChargeNote: string } +const CONTRACT_FIELD_GROUPS = [ + { + key: 'driverOne', + fields: [ + 'driverFirstName', + 'driverLastName', + 'driverBirthDate', + 'driverNationality', + 'driverAddress', + 'driverPhone', + 'driverCin', + 'driverPassport', + 'driverLicense', + 'driverLicenseIssuedAt', + 'driverLicenseExpiry', + ], + }, + { + key: 'driverTwo', + fields: [ + 'secondDriverFirstName', + 'secondDriverLastName', + 'secondDriverBirthDate', + 'secondDriverNationality', + 'secondDriverAddress', + 'secondDriverPhone', + 'secondDriverCin', + 'secondDriverPassport', + 'secondDriverLicense', + 'secondDriverLicenseIssuedAt', + 'secondDriverLicenseExpiry', + ], + }, + { + key: 'vehicle', + fields: [ + 'vehicleMakeModel', + 'vehicleRegistration', + 'vehicleDeparture', + 'vehicleReturn', + 'vehicleDuration', + 'vehicleFuelType', + 'vehicleExtensionReturn', + 'vehicleFuelLevel', + ], + }, +] as const + const detailCopy = { en: { editBooking: 'Edit booking', @@ -97,7 +179,53 @@ const detailCopy = { saveReturn: 'Save return', cancelEdit: 'Cancel', closeReservation: 'Close reservation', + openContract: 'Open contract', + generateContract: 'Generate contract', bookingDetails: 'Booking details', + contractFieldsTitle: 'Contract fields', + contractFieldsHint: 'Blank fields use saved customer, vehicle, and booking values. Company information comes from company settings.', + contractFieldGroupTitles: { + driverOne: 'Driver I', + driverTwo: 'Driver II', + vehicle: 'Vehicle', + }, + contractFieldLabels: { + companyName: 'Company name', + companyAddress: 'Company address', + contractCity: 'Contract city', + deliveryPlace: 'Delivery place', + returnPlace: 'Return place', + driverFirstName: 'First name', + driverLastName: 'Last name', + driverBirthDate: 'Date of birth', + driverNationality: 'Nationality', + driverAddress: 'Address', + driverPhone: 'Phone number', + driverCin: 'National ID number', + driverPassport: 'Passport number', + driverLicense: 'Driver license number', + driverLicenseIssuedAt: 'Issued on', + driverLicenseExpiry: 'Expires on', + secondDriverFirstName: 'First name', + secondDriverLastName: 'Last name', + secondDriverBirthDate: 'Date of birth', + secondDriverNationality: 'Nationality', + secondDriverAddress: 'Address', + secondDriverPhone: 'Phone number', + secondDriverCin: 'National ID number', + secondDriverPassport: 'Passport number', + secondDriverLicense: 'Driver license number', + secondDriverLicenseIssuedAt: 'Issued on', + secondDriverLicenseExpiry: 'Expires on', + vehicleMakeModel: 'Make / model', + vehicleRegistration: 'Registration number', + vehicleDeparture: 'Departure date', + vehicleReturn: 'Return date', + vehicleDuration: 'Rental duration', + vehicleFuelType: 'Fuel', + vehicleExtensionReturn: 'Return if extended', + vehicleFuelLevel: 'Fuel level', + }, returnDetails: 'Return closing', startLabel: 'Start date & time', endLabel: 'End date & time', @@ -105,6 +233,8 @@ const detailCopy = { returnLabel: 'Return location', depositLabel: 'Deposit', paymentModeLabel: 'Payment mode', + spareWheelLabel: 'Spare wheel', + radioCdLabel: 'Radio and CD', bookingNotesLabel: 'Booking notes', returnChargeLabel: 'Damage charge', returnChargeNoteLabel: 'Return note', @@ -164,7 +294,53 @@ const detailCopy = { saveReturn: 'Enregistrer le retour', cancelEdit: 'Annuler', closeReservation: 'Clôturer la réservation', + openContract: 'Ouvrir le contrat', + generateContract: 'Générer le contrat', bookingDetails: 'Détails de la réservation', + contractFieldsTitle: 'Champs du contrat', + contractFieldsHint: 'Les champs vides utilisent les valeurs client, véhicule et réservation enregistrées. Les informations société viennent des paramètres.', + contractFieldGroupTitles: { + driverOne: 'Conducteur I', + driverTwo: 'Conducteur II', + vehicle: 'Véhicule', + }, + contractFieldLabels: { + companyName: 'Nom de la société', + companyAddress: 'Adresse de la société', + contractCity: 'Ville du contrat', + deliveryPlace: 'Lieu de livraison', + returnPlace: 'Lieu de reprise', + driverFirstName: 'Prénom', + driverLastName: 'Nom', + driverBirthDate: 'Date de naissance', + driverNationality: 'Nationalité', + driverAddress: 'Adresse', + driverPhone: 'N° téléphone', + driverCin: 'N° de C.I.N.', + driverPassport: 'N° de passeport', + driverLicense: 'N° de permis', + driverLicenseIssuedAt: 'Délivrée le', + driverLicenseExpiry: 'Expire le', + secondDriverFirstName: 'Prénom', + secondDriverLastName: 'Nom', + secondDriverBirthDate: 'Date de naissance', + secondDriverNationality: 'Nationalité', + secondDriverAddress: 'Adresse', + secondDriverPhone: 'N° téléphone', + secondDriverCin: 'N° de C.I.N.', + secondDriverPassport: 'N° de passeport', + secondDriverLicense: 'N° de permis', + secondDriverLicenseIssuedAt: 'Délivrée le', + secondDriverLicenseExpiry: 'Expire le', + vehicleMakeModel: 'Marque / modèle', + vehicleRegistration: 'Matricule', + vehicleDeparture: 'Date de départ', + vehicleReturn: 'Date de retour', + vehicleDuration: 'Durée de location', + vehicleFuelType: 'Carburant', + vehicleExtensionReturn: 'Retour en cas de prolongation', + vehicleFuelLevel: 'Niveau carburant', + }, returnDetails: 'Clôture du retour', startLabel: 'Date et heure de départ', endLabel: 'Date et heure de retour', @@ -172,6 +348,8 @@ const detailCopy = { returnLabel: 'Lieu de retour', depositLabel: 'Dépôt', paymentModeLabel: 'Mode de paiement', + spareWheelLabel: 'Roue de secours', + radioCdLabel: 'Poste radio et CD', bookingNotesLabel: 'Notes de réservation', returnChargeLabel: 'Frais de dommage', returnChargeNoteLabel: 'Note de retour', @@ -231,7 +409,53 @@ const detailCopy = { saveReturn: 'حفظ الإرجاع', cancelEdit: 'إلغاء', closeReservation: 'إغلاق الحجز', + openContract: 'فتح العقد', + generateContract: 'إنشاء العقد', bookingDetails: 'تفاصيل الحجز', + contractFieldsTitle: 'حقول العقد', + contractFieldsHint: 'تستخدم الحقول الفارغة بيانات العميل والمركبة والحجز المحفوظة. تأتي بيانات الشركة من إعدادات الشركة.', + contractFieldGroupTitles: { + driverOne: 'السائق الأول', + driverTwo: 'السائق الثاني', + vehicle: 'المركبة', + }, + contractFieldLabels: { + companyName: 'اسم الشركة', + companyAddress: 'عنوان الشركة', + contractCity: 'مدينة العقد', + deliveryPlace: 'مكان التسليم', + returnPlace: 'مكان الإرجاع', + driverFirstName: 'الاسم الشخصي', + driverLastName: 'الاسم العائلي', + driverBirthDate: 'تاريخ الميلاد', + driverNationality: 'الجنسية', + driverAddress: 'العنوان', + driverPhone: 'رقم الهاتف', + driverCin: 'رقم البطاقة الوطنية', + driverPassport: 'رقم جواز السفر', + driverLicense: 'رقم رخصة القيادة', + driverLicenseIssuedAt: 'تاريخ الإصدار', + driverLicenseExpiry: 'تاريخ الانتهاء', + secondDriverFirstName: 'الاسم الشخصي', + secondDriverLastName: 'الاسم العائلي', + secondDriverBirthDate: 'تاريخ الميلاد', + secondDriverNationality: 'الجنسية', + secondDriverAddress: 'العنوان', + secondDriverPhone: 'رقم الهاتف', + secondDriverCin: 'رقم البطاقة الوطنية', + secondDriverPassport: 'رقم جواز السفر', + secondDriverLicense: 'رقم رخصة القيادة', + secondDriverLicenseIssuedAt: 'تاريخ الإصدار', + secondDriverLicenseExpiry: 'تاريخ الانتهاء', + vehicleMakeModel: 'الصانع / الطراز', + vehicleRegistration: 'رقم التسجيل', + vehicleDeparture: 'تاريخ الخروج', + vehicleReturn: 'تاريخ الإرجاع', + vehicleDuration: 'مدة الكراء', + vehicleFuelType: 'الوقود', + vehicleExtensionReturn: 'الإرجاع في حالة التمديد', + vehicleFuelLevel: 'مستوى الوقود', + }, returnDetails: 'إقفال الإرجاع', startLabel: 'تاريخ ووقت البداية', endLabel: 'تاريخ ووقت النهاية', @@ -239,6 +463,8 @@ const detailCopy = { returnLabel: 'موقع الإرجاع', depositLabel: 'العربون', paymentModeLabel: 'طريقة الدفع', + spareWheelLabel: 'العجلة الاحتياطية', + radioCdLabel: 'الراديو و CD', bookingNotesLabel: 'ملاحظات الحجز', returnChargeLabel: 'رسوم الأضرار', returnChargeNoteLabel: 'ملاحظة الإرجاع', @@ -307,6 +533,9 @@ function toFormState(reservation: ReservationDetail): ReservationFormState { returnLocation: reservation.returnLocation ?? '', depositAmount: String(reservation.depositAmount ?? 0), paymentMode: reservation.paymentMode ?? '', + spareWheel: reservation.spareWheel, + radioCd: reservation.radioCd, + contractFields: reservation.contractFields ?? {}, notes: reservation.notes ?? '', damageChargeAmount: reservation.damageChargeAmount !== null && reservation.damageChargeAmount !== undefined ? String(reservation.damageChargeAmount) : '', damageChargeNote: reservation.damageChargeNote ?? '', @@ -317,6 +546,80 @@ function toIsoString(value: string) { return new Date(value).toISOString() } +function readAddressField(address: Record | null | undefined, key: string) { + const value = address?.[key] + return typeof value === 'string' ? value : '' +} + +function formatContractDate(value: string | null | undefined) { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + return date.toLocaleDateString('fr-FR') +} + +function formatContractDateTime(value: string | null | undefined) { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + return date.toLocaleString('fr-FR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +function compactContractFields(fields: Record) { + return Object.fromEntries( + Object.entries(fields) + .map(([key, value]) => [key, value.trim()]) + .filter(([, value]) => value), + ) +} + +function buildContractFieldDefaults(reservation: ReservationDetail) { + const secondDriver = reservation.additionalDrivers[0] + return compactContractFields({ + driverFirstName: reservation.customer.firstName, + driverLastName: reservation.customer.lastName, + driverBirthDate: formatContractDate(reservation.customer.dateOfBirth), + driverNationality: reservation.customer.nationality ?? '', + driverAddress: readAddressField(reservation.customer.address, 'fullAddress'), + driverPhone: reservation.customer.phone ?? '', + driverCin: readAddressField(reservation.customer.address, 'identityDocumentNumber'), + driverPassport: readAddressField(reservation.customer.address, 'internationalLicenseNumber'), + driverLicense: reservation.customer.driverLicense ?? '', + driverLicenseIssuedAt: formatContractDate(reservation.customer.licenseIssuedAt), + driverLicenseExpiry: formatContractDate(reservation.customer.licenseExpiry), + secondDriverFirstName: secondDriver?.firstName ?? '', + secondDriverLastName: secondDriver?.lastName ?? '', + secondDriverBirthDate: formatContractDate(secondDriver?.dateOfBirth), + secondDriverNationality: secondDriver?.nationality ?? '', + secondDriverPhone: secondDriver?.phone ?? '', + secondDriverLicense: secondDriver?.driverLicense ?? '', + secondDriverLicenseIssuedAt: formatContractDate(secondDriver?.licenseIssuedAt), + secondDriverLicenseExpiry: formatContractDate(secondDriver?.licenseExpiry), + vehicleMakeModel: [reservation.vehicle.year, reservation.vehicle.make, reservation.vehicle.model].filter(Boolean).join(' '), + vehicleRegistration: reservation.vehicle.licensePlate, + vehicleDeparture: formatContractDateTime(reservation.startDate), + vehicleReturn: formatContractDateTime(reservation.endDate), + vehicleDuration: `${reservation.totalDays} days`, + vehicleFuelType: reservation.vehicle.fuelType ?? '', + }) +} + +function toReservationFormState(reservation: ReservationDetail): ReservationFormState { + return { + ...toFormState(reservation), + contractFields: { + ...buildContractFieldDefaults(reservation), + ...(reservation.contractFields ?? {}), + }, + } +} + export default function ReservationDetailPage() { const params = useParams<{ id: string }>() const { dict, language } = useDashboardI18n() @@ -325,6 +628,7 @@ export default function ReservationDetailPage() { const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US' const [reservation, setReservation] = useState(null) + const [billing, setBilling] = useState(null) const [inspections, setInspections] = useState([]) const [form, setForm] = useState(null) const [editMode, setEditMode] = useState(null) @@ -337,13 +641,15 @@ export default function ReservationDetailPage() { async function loadReservation() { try { - const [reservationData, inspectionData] = await Promise.all([ + const [reservationData, inspectionData, billingData] = await Promise.all([ apiFetch(`/reservations/${params.id}`), apiFetch(`/reservations/${params.id}/inspections`), + apiFetch(`/reservations/${params.id}/billing`), ]) setReservation(reservationData) setInspections(inspectionData) - setForm(toFormState(reservationData)) + setBilling(billingData) + setForm(toReservationFormState(reservationData)) setEditMode(null) setError(null) } catch (err: any) { @@ -386,6 +692,9 @@ export default function ReservationDetailPage() { returnLocation: form.returnLocation || null, depositAmount: Number(form.depositAmount || 0), paymentMode: form.paymentMode || null, + spareWheel: form.spareWheel, + radioCd: form.radioCd, + contractFields: form.contractFields, notes: form.notes || null, } : { @@ -461,7 +770,7 @@ export default function ReservationDetailPage() { new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' }) if (error) return
{error}
- if (!reservation || !form) return
{r.loadingReservation}
+ if (!reservation || !form || !billing) return
{r.loadingReservation}
const checkinInspection = inspections.find((inspection) => inspection.type === 'CHECKIN') const checkoutInspection = inspections.find((inspection) => inspection.type === 'CHECKOUT') @@ -480,6 +789,11 @@ export default function ReservationDetailPage() { const checkOutReadOnlyMessage = reservation.workflow.closed ? copy.inspectionClosed : copy.checkOutReadOnly + const chargeTableCopy = language === 'fr' + ? { item: 'Article', qty: 'Qté', total: 'Total', unitSuffix: 'chacun' } + : language === 'ar' + ? { item: 'البند', qty: 'الكمية', total: 'الإجمالي', unitSuffix: 'للوحدة' } + : { item: 'Item', qty: 'Qty', total: 'Total', unitSuffix: 'each' } return (
@@ -494,6 +808,9 @@ export default function ReservationDetailPage() { )}
+ + {reservation.workflow.contractGenerated ? copy.openContract : copy.generateContract} + {editMode === null && reservation.workflow.coreEditable && ( - @@ -524,7 +841,7 @@ export default function ReservationDetailPage() { - @@ -723,6 +1040,55 @@ export default function ReservationDetailPage() { onChange={(e) => setForm((current) => current ? { ...current, paymentMode: e.target.value } : current)} />
+ + +
+
+
+

{copy.contractFieldsTitle}

+

{copy.contractFieldsHint}

+
+
+ {CONTRACT_FIELD_GROUPS.map((group) => ( +
+
{copy.contractFieldGroupTitles[group.key]}
+
+ {group.fields.map((field) => ( + + ))} +
+
+ ))} +
@@ -734,13 +1100,63 @@ export default function ReservationDetailPage() { />
+
+ +
+
+

{r.sectionAdditionalDrivers}

+
+ {reservation.additionalDrivers.map((driver) => ( +
+
+
+

{driver.firstName} {driver.lastName}

+

{r.driverLicenseLabel} {driver.driverLicense}

+

{r.driverChargeLabel} {formatCurrency(driver.totalCharge, 'MAD')}

+
+ {driver.requiresApproval && !driver.approvedAt ? ( + + ) : ( + + {driver.approvedAt ? r.approvedBadge : r.noApprovalNeeded} + + )} +
+ {driver.approvalNote &&

{driver.approvalNote}

} +
+ ))} + {reservation.additionalDrivers.length === 0 && ( +
{r.noAdditionalDrivers}
+ )} +
+
+ +
+

{r.sectionInspectionSummary}

+
+
+ {r.checkOutInspectionLabel} + {checkoutInspection ? r.savedBadge : r.pendingBadge} +
+
+ {r.checkInInspectionLabel} + {checkinInspection ? r.savedBadge : r.pendingBadge} +
+
+ {copy.returnLabel} + {reservation.returnLocation ?? copy.noLocation} +
+
+
@@ -788,62 +1204,45 @@ export default function ReservationDetailPage() {

{r.sectionCharges}

-
-
{r.chargeDiscount}
{formatCurrency(reservation.discountAmount, 'MAD')}
-
{r.chargeInsurance}
{formatCurrency(reservation.insuranceTotal, 'MAD')}
-
{r.chargeAdditionalDrivers}
{formatCurrency(reservation.additionalDriverTotal, 'MAD')}
-
{r.chargePricingAdjustments}
{formatCurrency(reservation.pricingRulesTotal, 'MAD')}
-
{r.chargeGrandTotal}
{formatCurrency(reservation.totalAmount, 'MAD')}
+
+
+ {chargeTableCopy.item} + {chargeTableCopy.qty} + {chargeTableCopy.total} +
+
+ {billing.lineItems.map((item, index) => ( +
+
+

{item.description}

+

{formatCurrency(item.unitPrice, 'MAD')} {chargeTableCopy.unitSuffix}

+
+ {item.qty} + + {formatCurrency(item.total, 'MAD')} + +
+ ))} +
+
+ {r.chargeGrandTotal} + {formatCurrency(billing.grandTotal, 'MAD')} +
+
+
{copy.returnChargeLabel}
{formatCurrency(reservation.damageChargeAmount ?? 0, 'MAD')}
+
{r.chargeGrandTotal}
{formatCurrency(reservation.totalAmount, 'MAD')}
- - {reservation.insurances.length > 0 && ( -
-

{r.appliedInsurance}

-
- {reservation.insurances.map((insurance) => ( -
- {insurance.policyName} - {formatCurrency(insurance.totalCharge, 'MAD')} -
- ))} -
-
- )} - - {reservation.pricingRulesApplied && reservation.pricingRulesApplied.length > 0 && ( -
-

{r.pricingRulesApplied}

-
- {reservation.pricingRulesApplied.map((rule) => ( -
- {rule.name} - - {rule.amount < 0 ? '-' : '+'}{formatCurrency(Math.abs(rule.amount), 'MAD')} - -
- ))} -
-
- )}
- setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])} - /> setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])} /> -
- -
-
-

{r.sectionAdditionalDrivers}

-
- {reservation.additionalDrivers.map((driver) => ( -
-
-
-

{driver.firstName} {driver.lastName}

-

{r.driverLicenseLabel} {driver.driverLicense}

-

{r.driverChargeLabel} {formatCurrency(driver.totalCharge, 'MAD')}

-
- {driver.requiresApproval && !driver.approvedAt ? ( - - ) : ( - - {driver.approvedAt ? r.approvedBadge : r.noApprovalNeeded} - - )} -
- {driver.approvalNote &&

{driver.approvalNote}

} -
- ))} - {reservation.additionalDrivers.length === 0 && ( -
{r.noAdditionalDrivers}
- )} -
-
- -
-

{r.sectionInspectionSummary}

-
-
- {r.checkInInspectionLabel} - {checkinInspection ? r.savedBadge : r.pendingBadge} -
-
- {r.checkOutInspectionLabel} - {checkoutInspection ? r.savedBadge : r.pendingBadge} -
-
- {copy.returnLabel} - {reservation.returnLocation ?? copy.noLocation} -
-
-
+ setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])} + />
diff --git a/apps/dashboard/src/app/(dashboard)/reservations/page.tsx b/apps/dashboard/src/app/(dashboard)/reservations/page.tsx index bc1c296..f3b2f24 100644 --- a/apps/dashboard/src/app/(dashboard)/reservations/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/reservations/page.tsx @@ -66,6 +66,13 @@ export default function ReservationsPage() { } return language === 'fr' ? 'Voir la réservation' : language === 'ar' ? 'عرض الحجز' : 'View reservation' } + const contractActionLabel = (row: ReservationRow) => { + if (row.workflow?.contractGenerated || row.contractNumber) { + return language === 'fr' ? 'Ouvrir le contrat' : language === 'ar' ? 'فتح العقد' : 'Open contract' + } + return language === 'fr' ? 'Générer le contrat' : language === 'ar' ? 'إنشاء العقد' : 'Generate contract' + } + const contractColumnLabel = language === 'fr' ? 'Contrat' : language === 'ar' ? 'العقد' : 'Contract' const noVehiclesMessage = language === 'fr' ? 'Ajoutez au moins un véhicule à votre flotte avant de créer une réservation.' @@ -152,6 +159,7 @@ export default function ReservationsPage() { {r.colDates} {r.colSource} {r.colStatus} + {contractColumnLabel} {r.colTotal} @@ -171,12 +179,20 @@ export default function ReservationsPage() { {formatDate(row.startDate)} - {formatDateYear(row.endDate)} {row.source} {row.status} + + + {contractActionLabel(row)} + + {row.contractNumber ? ( +

{row.contractNumber}

+ ) : null} + {formatCurrency(row.totalAmount, 'MAD')} ))} {filteredRows.length === 0 && ( - {r.noReservations} + {r.noReservations} )} diff --git a/apps/dashboard/src/app/(dashboard)/subscription/page.tsx b/apps/dashboard/src/app/(dashboard)/subscription/page.tsx index a762e72..b2b8ca6 100644 --- a/apps/dashboard/src/app/(dashboard)/subscription/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/subscription/page.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from 'react' import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types' import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api' import { useDashboardI18n } from '@/components/I18nProvider' +import { buildHomepageSignInPath } from '@/lib/dashboardPaths' type Plan = 'STARTER' | 'GROWTH' | 'PRO' type BillingPeriod = 'MONTHLY' | 'ANNUAL' @@ -247,7 +248,7 @@ export default function SubscriptionPage() { .catch((err: any) => { if (cancelled) return if (err?.statusCode === 401) { - window.location.replace('/dashboard/sign-in?redirect=%2Fdashboard%2Fsubscription') + window.location.replace(buildHomepageSignInPath('/subscription')) return } if (err?.statusCode === 403) { diff --git a/apps/dashboard/src/app/(public)/sign-in/page.tsx b/apps/dashboard/src/app/(public)/sign-in/page.tsx index 55021f0..fd13fa1 100644 --- a/apps/dashboard/src/app/(public)/sign-in/page.tsx +++ b/apps/dashboard/src/app/(public)/sign-in/page.tsx @@ -1,5 +1,5 @@ -import { Suspense } from 'react' -import SignInPageClient from '@/app/sign-in/[[...sign-in]]/SignInPageClient' +import { redirect } from 'next/navigation' +import { buildHomepageSignInPath } from '@/lib/dashboardPaths' export default async function SignInPage({ searchParams, @@ -7,11 +7,17 @@ export default async function SignInPage({ searchParams: Promise> }) { const params = await searchParams - const embedded = params.embedded === '1' + const firstValue = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value + const destination = buildHomepageSignInPath(firstValue(params.redirect), { + locale: firstValue(params.lang), + theme: firstValue(params.theme), + }) + const destinationUrl = new URL(destination, 'http://localhost') - return ( - - - - ) + for (const key of ['portal', 'embedded']) { + const value = firstValue(params[key]) + if (value) destinationUrl.searchParams.set(key, value) + } + + redirect(`${destinationUrl.pathname}${destinationUrl.search}`) } diff --git a/apps/dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx b/apps/dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx index 14240e0..cce8453 100644 --- a/apps/dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx +++ b/apps/dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx @@ -1,11 +1,11 @@ "use client"; import Image from "next/image"; -import Link from "next/link"; import { useState } from "react"; import { useDashboardI18n } from "@/components/I18nProvider"; import PublicShell from "@/components/layout/PublicShell"; import { resolveApiBase } from "@/lib/api"; +import { buildHomepageSignInPath } from "@/lib/dashboardPaths"; import { carplaceUrl } from "@/lib/urls"; const DASHBOARD_LOGO_SRC = "/dashboard/rentaldrivego.png"; @@ -16,6 +16,7 @@ export default function ForgotPasswordPageClient({ embedded?: boolean; }) { const { language } = useDashboardI18n(); + const signInHref = buildHomepageSignInPath(undefined, { locale: language }); const dict = { en: { title: "Forgot your password?", @@ -173,12 +174,12 @@ export default function ForgotPasswordPageClient({ )}
- {dict.backToLogin} - +
diff --git a/apps/dashboard/src/app/onboarding/accept-invite/page.tsx b/apps/dashboard/src/app/onboarding/accept-invite/page.tsx index f12db2d..ee911d1 100644 --- a/apps/dashboard/src/app/onboarding/accept-invite/page.tsx +++ b/apps/dashboard/src/app/onboarding/accept-invite/page.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import Link from 'next/link' import PublicShell from '@/components/layout/PublicShell' +import { buildHomepageSignInPath } from '@/lib/dashboardPaths' export default function AcceptInvitePage() { return ( @@ -16,9 +16,9 @@ export default function AcceptInvitePage() {

Your invitation has been processed. You can now sign in to access the team dashboard.

- + Sign in to dashboard - +
diff --git a/apps/dashboard/src/app/public-auth-pages.test.ts b/apps/dashboard/src/app/public-auth-pages.test.ts index 7efd562..1334000 100644 --- a/apps/dashboard/src/app/public-auth-pages.test.ts +++ b/apps/dashboard/src/app/public-auth-pages.test.ts @@ -86,7 +86,7 @@ describe("dashboard public auth pages", () => { const text = collectText(page).join(" "); const signInLink = findElement( page, - (element) => element.props.href === "/sign-in", + (element) => element.props.href === "/en/light/sign-in", ); expect(text).toContain("Invitation accepted"); diff --git a/apps/dashboard/src/app/reset-password/ResetPasswordPageClient.tsx b/apps/dashboard/src/app/reset-password/ResetPasswordPageClient.tsx index 915bf34..79b715f 100644 --- a/apps/dashboard/src/app/reset-password/ResetPasswordPageClient.tsx +++ b/apps/dashboard/src/app/reset-password/ResetPasswordPageClient.tsx @@ -6,6 +6,7 @@ import { useSearchParams } from 'next/navigation' import { useDashboardI18n } from '@/components/I18nProvider' import PublicShell from '@/components/layout/PublicShell' import { resolveApiBase } from '@/lib/api' +import { buildHomepageSignInPath } from '@/lib/dashboardPaths' export default function ResetPasswordPageClient({ embedded = false }: { embedded?: boolean }) { return ( @@ -17,6 +18,7 @@ export default function ResetPasswordPageClient({ embedded = false }: { embedded function ResetPasswordContent({ embedded = false }: { embedded?: boolean }) { const { language } = useDashboardI18n() + const signInHref = buildHomepageSignInPath(undefined, { locale: language }) const dict = { en: { title: 'Set new password', @@ -132,7 +134,7 @@ function ResetPasswordContent({ embedded = false }: { embedded?: boolean }) {

{dict.successTitle}

{dict.successBody}

- {dict.signIn} + {dict.signIn}
) @@ -216,9 +218,9 @@ function ResetPasswordContent({ embedded = false }: { embedded?: boolean }) { ) diff --git a/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx b/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx deleted file mode 100644 index cf923f8..0000000 --- a/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx +++ /dev/null @@ -1,594 +0,0 @@ -"use client"; - -import Image from "next/image"; -import Link from "next/link"; -import { useEffect, useRef, useState } from "react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useDashboardI18n } from "@/components/I18nProvider"; -import PublicShell from "@/components/layout/PublicShell"; -import { adminUrl, websiteUrl } from "@/lib/urls"; -import { EMPLOYEE_PROFILE_KEY, resolveApiBase } from "@/lib/api"; -import { toPublicDashboardPath } from "@/lib/dashboardPaths"; - -const DASHBOARD_LOGO_SRC = "/dashboard/rentaldrivego.png"; - -function notifyParent(message: Record) { - if (typeof window === "undefined" || window.parent === window) return; - window.parent.postMessage(message, "*"); -} - -export default function SignInPageClient({ - embedded = false, -}: { - embedded?: boolean; -}) { - const { language, theme, setLanguage, setTheme } = useDashboardI18n(); - const pathname = usePathname(); - const router = useRouter(); - const searchParams = useSearchParams(); - const requestedLanguage = searchParams.get("lang"); - const requestedTheme = searchParams.get("theme"); - const initializedFromQuery = useRef(false); - const dict = { - en: { - brandName: "RentalDriveGo", - title: "Sign in", - subtitle: "Enter your credentials to access your account.", - email: "Email", - emailPlaceholder: "owner@company.com", - password: "Password", - signIn: "Sign in", - signingIn: "Signing in…", - verify: "Verify code", - verifying: "Verifying…", - authCode: "Authentication code", - enterCode: "Enter the 6-digit code from your authenticator app.", - totpPlaceholder: "000000 or XXXX-XXXX-XXXX", - back: "Back to credentials", - forgotPassword: "Forgot your password?", - invalidCredentials: "Invalid email or password.", - passwordNotSet: - 'No password set yet. Check your invitation email or use "Forgot your password?"', - tooManyRequests: "Too many attempts. Please try again later.", - emailNotVerified: - "Please verify your email first. Check your inbox for the verification link.", - resendVerification: "Resend verification email", - resendingVerification: "Sending…", - verificationResent: - "If that email is registered and not yet verified, a new verification link has been sent.", - unexpectedError: "Something went wrong. Please try again.", - }, - fr: { - brandName: "RentalDriveGo", - title: "Connexion", - subtitle: "Saisissez vos identifiants pour accéder à votre compte.", - email: "Email", - emailPlaceholder: "owner@company.com", - password: "Mot de passe", - signIn: "Connexion", - signingIn: "Connexion…", - verify: "Vérifier le code", - verifying: "Vérification…", - authCode: "Code d'authentification", - enterCode: - "Entrez le code à 6 chiffres de votre application d'authentification.", - totpPlaceholder: "000000 ou XXXX-XXXX-XXXX", - back: "Retour aux identifiants", - forgotPassword: "Mot de passe oublié ?", - invalidCredentials: "Adresse e-mail ou mot de passe invalide.", - passwordNotSet: `Aucun mot de passe défini. Vérifiez votre e-mail d'invitation ou utilisez « Mot de passe oublié ? »`, - tooManyRequests: "Trop de tentatives. Veuillez réessayer plus tard.", - emailNotVerified: - "Veuillez vérifier votre adresse email. Consultez votre boîte de réception.", - resendVerification: "Renvoyer l'e-mail de vérification", - resendingVerification: "Envoi…", - verificationResent: - "Si cette adresse existe et n'est pas encore vérifiée, un nouveau lien a été envoyé.", - unexpectedError: "Une erreur est survenue. Veuillez réessayer.", - }, - ar: { - brandName: "RentalDriveGo", - title: "تسجيل الدخول", - subtitle: "أدخل بياناتك للوصول إلى حسابك.", - email: "البريد الإلكتروني", - emailPlaceholder: "owner@company.com", - password: "كلمة المرور", - signIn: "تسجيل الدخول", - signingIn: "جارٍ تسجيل الدخول…", - verify: "تحقق من الرمز", - verifying: "جارٍ التحقق…", - authCode: "رمز المصادقة", - enterCode: "أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.", - totpPlaceholder: "000000 أو XXXX-XXXX-XXXX", - back: "العودة إلى بيانات الدخول", - forgotPassword: "نسيت كلمة المرور؟", - invalidCredentials: "البريد الإلكتروني أو كلمة المرور غير صحيحة.", - passwordNotSet: - 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"', - tooManyRequests: "محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.", - emailNotVerified: - "يرجى التحقق من بريدك الإلكتروني أولاً. تحقق من صندوق الوارد.", - resendVerification: "إعادة إرسال رسالة التحقق", - resendingVerification: "جارٍ الإرسال…", - verificationResent: - "إذا كان هذا البريد مسجلاً ولم يتم التحقق منه بعد، فقد تم إرسال رابط جديد.", - unexpectedError: "حدث خطأ ما. يرجى المحاولة مرة أخرى.", - }, - }[language]; - - useEffect(() => { - if (initializedFromQuery.current) return; - - if ( - (requestedLanguage === "en" || - requestedLanguage === "fr" || - requestedLanguage === "ar") && - requestedLanguage !== language - ) { - setLanguage(requestedLanguage); - } - - if ( - (requestedTheme === "light" || requestedTheme === "dark") && - requestedTheme !== theme - ) { - setTheme(requestedTheme); - } - - initializedFromQuery.current = true; - }, [ - language, - requestedLanguage, - requestedTheme, - setLanguage, - setTheme, - theme, - ]); - - useEffect(() => { - if (!initializedFromQuery.current) return; - - const params = new URLSearchParams(searchParams.toString()); - let changed = false; - - if (params.get("lang") !== language) { - params.set("lang", language); - changed = true; - } - - if (params.get("theme") !== theme) { - params.set("theme", theme); - changed = true; - } - - // useSearchParams() can return empty params during hydration without a Suspense boundary. - // Always preserve embedded=1 when the component was server-rendered as embedded so - // the router.replace doesn't strip it and trigger an unintended redirect. - if (embedded && params.get("embedded") !== "1") { - params.set("embedded", "1"); - changed = true; - } - - if (!changed) return; - - const nextQuery = params.toString(); - router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { - scroll: false, - }); - }, [embedded, language, pathname, router, searchParams, theme]); - - useEffect(() => { - const currentPath = - window.location.pathname + (window.location.search || ""); - notifyParent({ type: "rentaldrivego:embedded-path", path: currentPath }); - }, [pathname, searchParams]); - - return ( - -
-
-
-
- - RentalDriveGo - -
-

- {dict.subtitle} -

-
- {(["en", "fr", "ar"] as const).map((lang) => ( - - ))} -
-
- -
- -
-
-
-
- ); -} - -function LocalSignInForm({ - dict, -}: { - dict: { - email: string; - password: string; - signIn: string; - signingIn: string; - verify: string; - verifying: string; - authCode: string; - enterCode: string; - back: string; - forgotPassword: string; - invalidCredentials: string; - passwordNotSet: string; - tooManyRequests: string; - emailPlaceholder: string; - totpPlaceholder: string; - emailNotVerified: string; - resendVerification: string; - resendingVerification: string; - verificationResent: string; - unexpectedError: string; - }; -}) { - const searchParams = useSearchParams(); - const { setLanguage } = useDashboardI18n(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [totpCode, setTotpCode] = useState(""); - const [step, setStep] = useState<"credentials" | "totp">("credentials"); - const [showPassword, setShowPassword] = useState(false); - const [loading, setLoading] = useState(false); - const [resendingVerification, setResendingVerification] = useState(false); - const [error, setError] = useState(null); - const [canResendVerification, setCanResendVerification] = useState(false); - const requestedPortal = searchParams.get("portal"); - const employeeRedirect = searchParams.get("redirect") || "/dashboard"; - const preferAdminAuth = requestedPortal === "admin"; - - async function handleCredentials(e: React.FormEvent) { - e.preventDefault(); - setLoading(true); - setError(null); - setCanResendVerification(false); - - try { - const apiBase = resolveApiBase(); - - const tryAdminLogin = async () => { - const adminRes = await fetch(`${apiBase}/admin/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ email, password }), - }); - const adminJson = await adminRes.json(); - - if (adminRes.ok && adminJson?.data?.admin) { - window.location.href = `${adminUrl}/dashboard`; - return true; - } - - if (adminRes.status === 401 && adminJson?.error === "totp_required") { - setStep("totp"); - return true; - } - - if (adminRes.status === 429) { - setError(dict.tooManyRequests); - return true; - } - - return false; - }; - - const tryEmployeeLogin = async () => { - const empRes = await fetch(`${apiBase}/auth/employee/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ email, password }), - }); - const empJson = await empRes.json(); - - if (empRes.ok && empJson?.data?.employee) { - const targetPath = toPublicDashboardPath(employeeRedirect); - if (empJson?.data?.employee) { - localStorage.setItem( - EMPLOYEE_PROFILE_KEY, - JSON.stringify(empJson.data.employee), - ); - const prefLang = empJson.data.employee?.preferredLanguage; - if (prefLang === "en" || prefLang === "fr" || prefLang === "ar") { - setLanguage(prefLang); - document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`; - } - } - window.dispatchEvent(new CustomEvent("rentaldrivego:auth-changed")); - notifyParent({ - type: "rentaldrivego:employee-login", - path: targetPath, - }); - // Use a document navigation here so the authenticated dashboard bootstraps - // from a fresh request after the token cookie and localStorage are set. - window.location.replace(targetPath); - return true; - } - - if (empJson?.error === "password_not_set") { - setError(dict.passwordNotSet); - return true; - } - - if (empJson?.error === "email_not_verified") { - setError(dict.emailNotVerified); - setCanResendVerification(true); - return true; - } - - if (empRes.status === 429) { - setError(dict.tooManyRequests); - return true; - } - - return false; - }; - - if (preferAdminAuth) { - if (await tryAdminLogin()) return; - setError(dict.invalidCredentials); - return; - } - - if (await tryEmployeeLogin()) return; - - setError(dict.invalidCredentials); - } catch { - setError(dict.unexpectedError); - } finally { - setLoading(false); - } - } - - async function handleResendVerification() { - setResendingVerification(true); - setError(null); - - try { - const res = await fetch(`${resolveApiBase()}/auth/employee/resend-verification`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ email }), - }); - - if (!res.ok) throw new Error(`HTTP ${res.status}`); - - setCanResendVerification(false); - setError(dict.verificationResent); - } catch { - setError(dict.unexpectedError); - setCanResendVerification(true); - } finally { - setResendingVerification(false); - } - } - - async function handleTotp(e: React.FormEvent) { - e.preventDefault(); - setLoading(true); - setError(null); - - try { - const normalizedCode = totpCode.trim().toUpperCase(); - const secondFactor = /^\d{6}$/.test(normalizedCode) - ? { totpCode: normalizedCode } - : { recoveryCode: normalizedCode }; - - const adminRes = await fetch(`${resolveApiBase()}/admin/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ email, password, ...secondFactor }), - }); - const adminJson = await adminRes.json(); - - if (adminRes.ok && adminJson?.data?.admin) { - window.location.href = `${adminUrl}/dashboard`; - return; - } - - setError(dict.invalidCredentials); - } catch { - setError(dict.unexpectedError); - } finally { - setLoading(false); - } - } - - return ( - <> - {error ? ( -
-
{error}
- {canResendVerification ? ( - - ) : null} -
- ) : null} - - {step === "credentials" ? ( -
-
- - setEmail(e.target.value)} - placeholder={dict.emailPlaceholder} - className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500" - /> -
- -
- -
- setPassword(e.target.value)} - placeholder="••••••••" - className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500" - /> - -
-
- - - -
- - {dict.forgotPassword} - -
-
- ) : ( -
-
- {dict.enterCode} -
- -
- - - setTotpCode( - e.target.value.replace(/[^0-9A-Za-z-]/g, "").toUpperCase(), - ) - } - placeholder={dict.totpPlaceholder} - className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-center text-xl tracking-[0.45em] text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500" - /> -
- - - - -
- )} - - ); -} diff --git a/apps/dashboard/src/app/sign-up/[[...sign-up]]/SignUpForm.tsx b/apps/dashboard/src/app/sign-up/[[...sign-up]]/SignUpForm.tsx index 606cd82..98c7774 100644 --- a/apps/dashboard/src/app/sign-up/[[...sign-up]]/SignUpForm.tsx +++ b/apps/dashboard/src/app/sign-up/[[...sign-up]]/SignUpForm.tsx @@ -6,6 +6,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { apiFetch } from "@/lib/api"; import PublicShell from "@/components/layout/PublicShell"; import { useDashboardI18n } from "@/components/I18nProvider"; +import { buildHomepageSignInPath } from "@/lib/dashboardPaths"; import { websiteUrl } from "@/lib/urls"; const DASHBOARD_LOGO_SRC = "/dashboard/rentaldrivego.png"; @@ -16,17 +17,17 @@ function notifyParent(message: Record) { } function withAuthQuery( - path: string, params: { embedded: boolean; language: "en" | "fr" | "ar"; theme: string }, ) { - const search = new URLSearchParams({ - lang: params.language, + const href = buildHomepageSignInPath(undefined, { + locale: params.language, theme: params.theme, }); + const url = new URL(href, "http://localhost"); - if (params.embedded) search.set("embedded", "1"); + if (params.embedded) url.searchParams.set("embedded", "1"); - return `${path}?${search.toString()}`; + return `${url.pathname}${url.search}`; } export default function SignUpForm({ @@ -234,7 +235,7 @@ export default function SignUpForm({ }, }[preferredLanguage]; - const signInHref = withAuthQuery("/sign-in", { + const signInHref = withAuthQuery({ embedded, language: preferredLanguage, theme, diff --git a/apps/dashboard/src/app/verify-email/page.tsx b/apps/dashboard/src/app/verify-email/page.tsx index 9e67fa0..5d54973 100644 --- a/apps/dashboard/src/app/verify-email/page.tsx +++ b/apps/dashboard/src/app/verify-email/page.tsx @@ -6,9 +6,11 @@ import Image from 'next/image' import PublicShell from '@/components/layout/PublicShell' import { useDashboardI18n } from '@/components/I18nProvider' import { resolveApiBase } from '@/lib/api' +import { buildHomepageSignInPath } from '@/lib/dashboardPaths' export default function VerifyEmailPage() { const { language } = useDashboardI18n() + const signInHref = buildHomepageSignInPath(undefined, { locale: language }) const searchParams = useSearchParams() const token = searchParams.get('token') const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading') @@ -113,7 +115,7 @@ export default function VerifyEmailPage() {

{dict.success}

{dict.signIn} @@ -132,7 +134,7 @@ export default function VerifyEmailPage() {

{errorDetail}

)}
{dict.signIn} diff --git a/apps/dashboard/src/components/layout/DashboardAccessGuard.boundary.test.ts b/apps/dashboard/src/components/layout/DashboardAccessGuard.boundary.test.ts index 2bf2f96..2091a72 100644 --- a/apps/dashboard/src/components/layout/DashboardAccessGuard.boundary.test.ts +++ b/apps/dashboard/src/components/layout/DashboardAccessGuard.boundary.test.ts @@ -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', () => { diff --git a/apps/dashboard/src/components/layout/DashboardAccessGuard.tsx b/apps/dashboard/src/components/layout/DashboardAccessGuard.tsx index 23bcdfd..3d74449 100644 --- a/apps/dashboard/src/components/layout/DashboardAccessGuard.tsx +++ b/apps/dashboard/src/components/layout/DashboardAccessGuard.tsx @@ -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 = { 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 }) { diff --git a/apps/dashboard/src/components/layout/Sidebar.tsx b/apps/dashboard/src/components/layout/Sidebar.tsx index 910d5ad..1001a4b 100644 --- a/apps/dashboard/src/components/layout/Sidebar.tsx +++ b/apps/dashboard/src/components/layout/Sidebar.tsx @@ -90,6 +90,7 @@ function toSidebarUser(profile: Partial, 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' }, diff --git a/apps/dashboard/src/components/reservations/new/ReservationReview.tsx b/apps/dashboard/src/components/reservations/new/ReservationReview.tsx index c62ee0f..638414d 100644 --- a/apps/dashboard/src/components/reservations/new/ReservationReview.tsx +++ b/apps/dashboard/src/components/reservations/new/ReservationReview.tsx @@ -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)], ], diff --git a/apps/dashboard/src/components/reservations/new/ReservationWizard.tsx b/apps/dashboard/src/components/reservations/new/ReservationWizard.tsx index 825914b..b453a40 100644 --- a/apps/dashboard/src/components/reservations/new/ReservationWizard.tsx +++ b/apps/dashboard/src/components/reservations/new/ReservationWizard.tsx @@ -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 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({ {copy.notes}