fix the booking and contract
Build & Push / Pipeline Tests (push) Failing after 1m31s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 55s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Failing after 46s
Test / API Integration Tests (push) Successful in 1m7s
Build & Push / Pipeline Tests (push) Failing after 1m31s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 55s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Failing after 46s
Test / API Integration Tests (push) Successful in 1m7s
This commit is contained in:
@@ -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<string, unknown>)
|
||||
: {}
|
||||
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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, string> {
|
||||
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<string, string>
|
||||
}
|
||||
|
||||
function readAddressField(address: unknown, key: string): string | null {
|
||||
const extras = parseReservationExtras(address)
|
||||
const value = extras[key]
|
||||
@@ -125,10 +141,14 @@ export function serializeReservationForDashboard<T extends {
|
||||
} | null
|
||||
}>(reservation: T): T & {
|
||||
paymentMode: string | null
|
||||
spareWheel: boolean
|
||||
radioCd: boolean
|
||||
contractFields: Record<string, string>
|
||||
workflow: ReturnType<typeof buildReservationWorkflow>
|
||||
bookingRequest: ReturnType<typeof buildBookingRequestProgress>
|
||||
} {
|
||||
const extras = parseReservationExtras(reservation.extras)
|
||||
const contractFields = serializeContractFields(extras.contractFields)
|
||||
const customer =
|
||||
reservation.customer
|
||||
? {
|
||||
@@ -143,6 +163,9 @@ export function serializeReservationForDashboard<T extends {
|
||||
...reservation,
|
||||
...(customer !== undefined ? { customer } : {}),
|
||||
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
|
||||
spareWheel: typeof extras.spareWheel === 'boolean' ? extras.spareWheel : false,
|
||||
radioCd: typeof extras.radioCd === 'boolean' ? extras.radioCd : false,
|
||||
contractFields,
|
||||
workflow: buildReservationWorkflow(reservation),
|
||||
bookingRequest: buildBookingRequestProgress({ ...reservation, customer }),
|
||||
}
|
||||
|
||||
@@ -42,11 +42,16 @@ export const createSchema = z.object({
|
||||
promoCodeUsed: z.string().optional(),
|
||||
depositAmount: z.number().int().min(0).default(0),
|
||||
paymentMode: z.string().max(50).optional(),
|
||||
spareWheel: z.boolean().optional(),
|
||||
radioCd: z.boolean().optional(),
|
||||
notes: z.string().optional(),
|
||||
contractFields: z.record(z.string().max(120), z.string().max(500).nullable()).optional(),
|
||||
selectedInsurancePolicyIds: z.array(z.string()).default([]),
|
||||
additionalDrivers: z.array(additionalDriverSchema).default([]),
|
||||
})
|
||||
|
||||
export const contractFieldsSchema = z.record(z.string().max(120), z.string().max(500).nullable()).optional()
|
||||
|
||||
export const updateSchema = z.object({
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
@@ -55,6 +60,9 @@ export const updateSchema = z.object({
|
||||
depositAmount: z.number().int().min(0).optional(),
|
||||
notes: z.string().optional().nullable(),
|
||||
paymentMode: z.string().max(50).optional().nullable(),
|
||||
spareWheel: z.boolean().optional().nullable(),
|
||||
radioCd: z.boolean().optional().nullable(),
|
||||
contractFields: contractFieldsSchema,
|
||||
damageChargeAmount: z.number().int().min(0).optional(),
|
||||
damageChargeNote: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
@@ -4,12 +4,24 @@ import { validateAndFlagLicense } from '../../services/licenseValidationService'
|
||||
import { applyPricingRules, calculateUpdatedInsuranceCharge, calculateUpdatedAdditionalDriverCharge } from './reservation.pricing.service'
|
||||
import { applyInsurancesToReservation } from './reservation.insurance.service'
|
||||
import { applyAdditionalDriversToReservation } from './reservation.additional-driver.service'
|
||||
import { parseReservationExtras, normalizeOptionalString, serializeReservationForDashboard, buildReservationWorkflow } from './reservation.presenter'
|
||||
import { parseReservationExtras, normalizeOptionalString, serializeContractFields, serializeReservationForDashboard, buildReservationWorkflow } from './reservation.presenter'
|
||||
import * as repo from './reservation.repo'
|
||||
|
||||
const RESERVATION_STATUSES = ['DRAFT', 'CONFIRMED', 'ACTIVE', 'COMPLETED', 'CANCELLED', 'NO_SHOW'] as const
|
||||
const BOOKING_SOURCES = ['DASHBOARD', 'PUBLIC_SITE', 'CARPLACE', 'API'] as const
|
||||
|
||||
function normalizeContractFields(value: Record<string, string | null> | undefined) {
|
||||
if (!value) return null
|
||||
const fields: Record<string, string> = {}
|
||||
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<string, string | null>
|
||||
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<string, string | null>
|
||||
}) {
|
||||
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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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<string, string>
|
||||
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 (
|
||||
<div className={`border border-stone-400/80 ${className}`}>
|
||||
<div className="border-b border-stone-300 bg-stone-100 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-stone-600">
|
||||
{label}
|
||||
</div>
|
||||
<div className="min-h-[38px] bg-[#fff5ea] px-2 py-2 text-sm text-stone-900">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PaperSection({
|
||||
title,
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<section className={`border border-stone-500 ${className}`}>
|
||||
<div className="border-b border-stone-500 bg-stone-100 px-3 py-1.5 text-[11px] font-bold uppercase tracking-[0.12em] text-stone-700">
|
||||
{title}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6 print:space-y-0">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between print:hidden">
|
||||
<div className="space-y-2">
|
||||
<Link href="/contracts" className="text-sm font-semibold text-blue-700 hover:underline">{copy.back}</Link>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-slate-500">{contract.company.name}</p>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-slate-500">{companyName}</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="text-xl font-semibold text-slate-900">{contract.contractNumber ?? copy.contractNo}</h2>
|
||||
<span className="badge-blue">{tl(copy.contractStatusLabels, contract.status)}</span>
|
||||
@@ -906,235 +975,256 @@ export default function ContractDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-8 print:space-y-0">
|
||||
<section className="mx-auto max-w-[1060px] overflow-hidden rounded-[28px] border-4 border-stone-900 bg-white text-stone-900 shadow-2xl print:max-w-none print:shadow-none print:break-after-page">
|
||||
<div className="border-b border-stone-500 px-6 py-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||
{contract.company.logoUrl && (
|
||||
<div className="flex shrink-0 items-center justify-center">
|
||||
<img
|
||||
src={contract.company.logoUrl}
|
||||
alt={contract.company.name}
|
||||
className="h-24 w-24 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-stone-500">
|
||||
{contract.company.name}
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-black tracking-tight">
|
||||
{contract.company.name} {bl(copy.agreementTitle, arCopy.agreementTitle)}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm font-medium text-stone-600">
|
||||
{bl(copy.agreementForReservation, arCopy.agreementForReservation)} {contract.contractNumber ?? contract.reservationId.slice(-8).toUpperCase()}
|
||||
</p>
|
||||
<section className="mx-auto max-w-[1060px] rounded-lg border-2 border-neutral-950 bg-white p-4 text-[13.5px] leading-[1.25] text-neutral-950 shadow-2xl print:h-[calc(100vh-10mm)] print:max-w-none print:rounded-none print:border print:p-1.5 print:text-[8px] print:shadow-none print:break-after-page">
|
||||
<header className="grid w-full grid-cols-[31fr_69fr] items-start gap-3 print:gap-1.5">
|
||||
<div className="flex h-[86px] flex-col items-start justify-start px-2 text-left print:h-[52px] print:px-1">
|
||||
<img src={companyLogoSrc} alt={companyName} className="mb-1 h-10 max-w-[120px] object-contain object-left print:h-6 print:max-w-[80px]" />
|
||||
<div className="text-left text-xl italic tracking print:text-[11px]">
|
||||
{companyName}
|
||||
</div>
|
||||
<div className="grid min-w-[220px] grid-cols-2 gap-px self-stretch overflow-hidden rounded-2xl border border-stone-400 bg-stone-300 text-sm">
|
||||
<div className="bg-white px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{bl(copy.status, arCopy.status)}</p>
|
||||
<p className="mt-1 font-semibold">{tl(copy.contractStatusLabels, contract.status)}</p>
|
||||
</div>
|
||||
<div className="bg-white px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{bl(copy.paymentStatus, arCopy.paymentStatus)}</p>
|
||||
<p className="mt-1 font-semibold">{tl(copy.paymentStatusLabels, contract.paymentStatus)}</p>
|
||||
</div>
|
||||
<div className="bg-white px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{bl(copy.contractNo, arCopy.contractNo)}</p>
|
||||
<p className="mt-1 font-semibold">{contract.contractNumber ?? '—'}</p>
|
||||
</div>
|
||||
<div className="bg-white px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{bl(copy.generated, arCopy.generated)}</p>
|
||||
<p className="mt-1 font-semibold">{formatDateTime(contract.generatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="font-serif text-3xl font-bold italic underline print:text-[18px]">
|
||||
<span className="block whitespace-nowrap">Contrat de location de voiture</span>
|
||||
<span className="block whitespace-nowrap" dir="rtl">عقد كراء سيارة</span>
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mt-1.5 grid w-full grid-cols-2 overflow-hidden rounded-sm border border-neutral-950 text-center font-bold print:mt-1 print:rounded-none">
|
||||
<div className="border-r border-neutral-950">
|
||||
<div className="bg-neutral-200 px-3 py-1 text-sm print:px-2 print:py-0.5 print:text-[8px]">Date de départ</div>
|
||||
<div className="border-t border-neutral-950 px-3 py-1.5 print:px-2 print:py-0.5">
|
||||
{[contractCity, formatDateTime(contract.rentalPeriod.startDate)].filter(Boolean).join(' le ')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-neutral-200 px-3 py-1 text-sm print:px-2 print:py-0.5 print:text-[8px]">N° Contrat</div>
|
||||
<div className="border-t border-neutral-950 px-3 py-1.5 print:px-2 print:py-0.5">
|
||||
{contract.contractNumber ?? copy.draftAgreement}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 p-6 lg:grid-cols-[1.6fr_1fr] print:grid-cols-[1.6fr_1fr]">
|
||||
<div className="space-y-5">
|
||||
<PaperSection title={bl(copy.customerInformation, arCopy.customerInformation)}>
|
||||
<div className="grid gap-px bg-stone-300 sm:grid-cols-2">
|
||||
<PaperField label={bl(copy.customerName, arCopy.customerName)} value={driverFullName} className="bg-white sm:col-span-2" />
|
||||
<PaperField label={bl(copy.homeAddress, arCopy.homeAddress)} value={address || copy.none} className="bg-white sm:col-span-2" />
|
||||
<PaperField label={bl(copy.cityCountry, arCopy.cityCountry)} value={[contract.company.city, contract.company.country].filter(Boolean).join(', ') || copy.none} className="bg-white" />
|
||||
<PaperField label={bl(copy.telephone, arCopy.telephone)} value={contract.driver.phone ?? copy.none} className="bg-white" />
|
||||
<PaperField label={bl(copy.emailLabel, arCopy.emailLabel)} value={contract.driver.email} className="bg-white sm:col-span-2" />
|
||||
<PaperField label={bl(copy.driverLicenseNumber, arCopy.driverLicenseNumber)} value={contract.driver.driverLicense ?? copy.none} className="bg-white" />
|
||||
<PaperField label={bl(copy.nationalityLabel, arCopy.nationalityLabel)} value={contract.driver.nationality ?? copy.none} className="bg-white" />
|
||||
<PaperField label={bl(copy.birthDate, arCopy.birthDate)} value={formatDateOnly(contract.driver.dateOfBirth, localeCode, copy.none)} className="bg-white" />
|
||||
<PaperField label={bl(copy.issuedExpires, arCopy.issuedExpires)} value={`${formatDateOnly(contract.driver.licenseIssuedAt, localeCode, copy.none)} — ${formatDateOnly(contract.driver.licenseExpiry, localeCode, copy.none)}`} className="bg-white" />
|
||||
</div>
|
||||
</PaperSection>
|
||||
<div className="mt-1.5 grid w-full grid-cols-2 overflow-hidden rounded-sm border border-neutral-950 text-center font-bold print:mt-1 print:rounded-none">
|
||||
<div className="border-r border-neutral-950 px-3 py-2 print:px-2 print:py-1">Lieu de livraison : {contract.rentalPeriod.pickupLocation ?? contract.company.city ?? copy.none}</div>
|
||||
<div className="px-3 py-2 print:px-2 print:py-1">Lieu de reprise : {contract.rentalPeriod.returnLocation ?? contract.company.city ?? copy.none}</div>
|
||||
</div>
|
||||
|
||||
<PaperSection title={bl(copy.rentalPeriodDetails, arCopy.rentalPeriodDetails)}>
|
||||
<div className="grid gap-px bg-stone-300 sm:grid-cols-2">
|
||||
<PaperField label={bl(copy.dateOut, arCopy.dateOut)} value={formatDateTime(contract.rentalPeriod.startDate)} className="bg-white" />
|
||||
<PaperField label={bl(copy.dateDueIn, arCopy.dateDueIn)} value={formatDateTime(contract.rentalPeriod.endDate)} className="bg-white" />
|
||||
<PaperField label={bl(copy.mileageOut, arCopy.mileageOut)} value={String(contract.inspections.checkIn?.mileage ?? copy.none)} className="bg-white" />
|
||||
<PaperField label={bl(copy.mileageIn, arCopy.mileageIn)} value={String(contract.inspections.checkOut?.mileage ?? copy.none)} className="bg-white" />
|
||||
<PaperField label={bl(copy.pickupLocationLabel, arCopy.pickupLocationLabel)} value={contract.rentalPeriod.pickupLocation ?? copy.none} className="bg-white" />
|
||||
<PaperField label={bl(copy.returnLocationLabel, arCopy.returnLocationLabel)} value={contract.rentalPeriod.returnLocation ?? copy.none} className="bg-white" />
|
||||
<PaperField label={bl(copy.additionalDrivers, arCopy.additionalDrivers)} value={additionalDriverNames} className="bg-white sm:col-span-2" />
|
||||
</div>
|
||||
</PaperSection>
|
||||
|
||||
<PaperSection title={bl(copy.insuranceCondition, arCopy.insuranceCondition)}>
|
||||
<div className="grid gap-px bg-stone-300">
|
||||
<PaperField
|
||||
label={bl(copy.insuranceSelections, arCopy.insuranceSelections)}
|
||||
value={contract.insurance.policies.length > 0 ? contract.insurance.policies.map((policy) => `${policy.name} (${tl(copy.chargeTypeLabels, policy.chargeType)})`).join(', ') : copy.noInsurance}
|
||||
className="bg-white"
|
||||
/>
|
||||
<PaperField label={bl(copy.fuel, arCopy.fuel)} value={contract.terms.fuelPolicy ?? copy.none} className="bg-white" />
|
||||
<PaperField label={bl(copy.additionalDriverPolicy, arCopy.additionalDriverPolicy)} value={contract.terms.additionalDriverPolicy ?? copy.none} className="bg-white" />
|
||||
<PaperField label={bl(copy.damage, arCopy.damage)} value={contract.terms.damagePolicy ?? copy.none} className="bg-white" />
|
||||
<PaperField
|
||||
label={bl(copy.checkInCondition, arCopy.checkInCondition)}
|
||||
value={contract.inspections.checkIn?.generalCondition ?? contract.inspections.checkIn?.employeeNotes ?? copy.none}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
</PaperSection>
|
||||
|
||||
<PaperSection title={bl(copy.acknowledgementSignature, arCopy.acknowledgementSignature)}>
|
||||
<div className="space-y-4 bg-white p-4 text-sm leading-6 text-stone-700">
|
||||
<p>{copy.signatureAcknowledgement}</p>
|
||||
{isBilingual && (
|
||||
<p className="text-right" dir="rtl">{arCopy.signatureAcknowledgement}</p>
|
||||
)}
|
||||
<p>
|
||||
{bl(copy.signature, arCopy.signature)}: <span className="font-semibold text-stone-900">{contract.terms.signatureRequired ? copy.yes : copy.no}</span>
|
||||
</p>
|
||||
<div className="grid gap-4 pt-4 sm:grid-cols-2">
|
||||
<div className="border-t border-dashed border-stone-500 pt-2 text-sm font-medium text-stone-800">
|
||||
{bl(copy.customerSignature, arCopy.customerSignature)}
|
||||
</div>
|
||||
<div className="border-t border-dashed border-stone-500 pt-2 text-sm font-medium text-stone-800">
|
||||
{bl(copy.companyRepresentative, arCopy.companyRepresentative)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PaperSection>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<PaperSection title={bl(copy.vehicleInformation, arCopy.vehicleInformation)}>
|
||||
<div className="grid gap-px bg-stone-300">
|
||||
<PaperField label={bl(copy.yearMakeModel, arCopy.yearMakeModel)} value={`${contract.vehicle.year} ${contract.vehicle.make} ${contract.vehicle.model}`} className="bg-white" />
|
||||
<PaperField label={bl(copy.licensePlateLabel, arCopy.licensePlateLabel)} value={contract.vehicle.licensePlate} className="bg-white" />
|
||||
<PaperField label={bl(copy.vehicleColor, arCopy.vehicleColor)} value={contract.vehicle.color} className="bg-white" />
|
||||
<PaperField label={bl(copy.categoryLabel, arCopy.categoryLabel)} value={contract.vehicle.category} className="bg-white" />
|
||||
<PaperField label={bl(copy.vinLabel, arCopy.vinLabel)} value={contract.vehicle.vin ?? copy.none} className="bg-white" />
|
||||
<div className="border border-stone-400/80 bg-white px-4 py-5">
|
||||
<p className="mb-3 text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{bl(copy.damageDiagram, arCopy.damageDiagram)}</p>
|
||||
<div className="flex justify-center py-2">
|
||||
<VehicleConditionSheet points={damagePoints} className="w-full max-w-[360px] rounded-2xl border border-slate-200 bg-white p-3" />
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-stone-600">
|
||||
{damagePoints.length > 0 ? copy.damageMarkersRecorded(damagePoints.length) : copy.noDamage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PaperSection>
|
||||
|
||||
<PaperSection title={bl(copy.invoice, arCopy.invoice)}>
|
||||
<div className="space-y-3 bg-white p-4 text-sm text-stone-700">
|
||||
<div className="grid gap-px bg-stone-300 sm:grid-cols-2">
|
||||
<PaperField label={bl(copy.invoiceNo, arCopy.invoiceNo)} value={contract.invoiceNumber ?? copy.draftAgreement} className="bg-white" />
|
||||
<PaperField label={bl(copy.contractNo, arCopy.contractNo)} value={contract.contractNumber ?? copy.draftAgreement} className="bg-white" />
|
||||
<PaperField label={bl(copy.customerName, arCopy.customerName)} value={driverFullName} className="bg-white" />
|
||||
<PaperField label={bl(copy.telephone, arCopy.telephone)} value={contract.driver.phone ?? copy.none} className="bg-white" />
|
||||
<PaperField label={bl(copy.emailLabel, arCopy.emailLabel)} value={contract.driver.email} className="bg-white sm:col-span-2" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{bl(copy.chargesAndFees, arCopy.chargesAndFees)}</p>
|
||||
</div>
|
||||
{contract.invoice.lineItems.map((item, index) => (
|
||||
<div key={`${item.description}-${index}`} className="flex items-start justify-between gap-3 border-b border-stone-200 pb-2 last:border-b-0">
|
||||
<div>
|
||||
<p className="font-medium text-stone-900">{item.description}</p>
|
||||
<p className="text-xs text-stone-500">{copy.qty} {item.qty} · {formatCurrency(item.unitPrice, contract.invoice.currency)}</p>
|
||||
</div>
|
||||
<p className="font-semibold text-stone-900">{formatCurrency(item.total, contract.invoice.currency)}</p>
|
||||
<div className="mt-1.5 grid w-full grid-cols-[52fr_48fr] gap-2 print:mt-1 print:gap-1">
|
||||
<div className="min-w-0 space-y-1.5 print:space-y-1">
|
||||
<section className="w-full overflow-hidden rounded-sm border border-neutral-950 print:rounded-none">
|
||||
<div className="border-b border-neutral-950 bg-neutral-200 px-2 py-1.5 text-center font-serif text-base font-bold print:px-1.5 print:py-1 print:text-[9.5px]">Conducteur I</div>
|
||||
<div className="space-y-1 p-2.5 print:space-y-0.5 print:p-1.5">
|
||||
{[
|
||||
['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]) => (
|
||||
<div key={label} className="grid min-h-6 grid-cols-[34%_43%_23%] items-center gap-1.5 print:min-h-[17px] print:gap-1">
|
||||
<span className="px-1 font-semibold">{label}</span>
|
||||
<span className="min-h-[18px] border-b border-dotted border-neutral-500 px-2 py-0.5 font-bold print:min-h-[14px] print:px-1">{value}</span>
|
||||
<span className="px-1 text-right font-semibold" dir="rtl">{arLabel}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-2 rounded-xl border border-stone-300 bg-stone-50 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{bl(copy.subtotal, arCopy.subtotal)}</span>
|
||||
<span className="font-semibold text-stone-900">{formatCurrency(contract.invoice.subtotal, contract.invoice.currency)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{bl(copy.taxesLabel, arCopy.taxesLabel)}</span>
|
||||
<span className="font-semibold text-stone-900">
|
||||
{contract.invoice.taxes.length > 0
|
||||
? formatCurrency(contract.invoice.taxTotal, contract.invoice.currency)
|
||||
: copy.none}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-stone-200 pt-2">
|
||||
<span className="font-semibold text-stone-900">{bl(copy.totalChargeLabel, arCopy.totalChargeLabel)}</span>
|
||||
<span className="font-bold text-stone-900">{formatCurrency(contract.invoice.total, contract.invoice.currency)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{bl(copy.amountPaidLabel, arCopy.amountPaidLabel)}</span>
|
||||
<span className="font-semibold text-stone-900">{formatCurrency(contract.invoice.amountPaid, contract.invoice.currency)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{bl(copy.balanceDueLabel, arCopy.balanceDueLabel)}</span>
|
||||
<span className="font-semibold text-stone-900">{formatCurrency(contract.invoice.balanceDue, contract.invoice.currency)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{bl(copy.paymentMode, arCopy.paymentMode)}</span>
|
||||
<span className="font-semibold text-stone-900">{contract.paymentMode ?? latestPayment?.paymentMethod ?? copy.none}</span>
|
||||
</div>
|
||||
</div>
|
||||
{latestPayment ? (
|
||||
<div className="rounded-xl border border-stone-300 bg-stone-50 px-3 py-2 text-xs text-stone-600">
|
||||
{bl(copy.lastPayment, arCopy.lastPayment)}: {tl(copy.paymentProviderLabels, latestPayment.provider)} · {tl(copy.paymentStatusLabels, latestPayment.status)} · {formatDateTime(latestPayment.paidAt ?? latestPayment.createdAt)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PaperSection>
|
||||
</section>
|
||||
|
||||
<section className="w-full overflow-hidden rounded-sm border border-neutral-950 print:rounded-none">
|
||||
<div className="border-b border-neutral-950 bg-neutral-200 px-2 py-1.5 text-center font-serif text-base font-bold print:px-1.5 print:py-1 print:text-[9.5px]">Conducteur II</div>
|
||||
{hasSecondDriver ? (
|
||||
<div className="space-y-1 p-2.5 print:space-y-0.5 print:p-1.5">
|
||||
{[
|
||||
['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]) => (
|
||||
<div key={label} className="grid min-h-6 grid-cols-[34%_43%_23%] items-center gap-1.5 print:min-h-[17px] print:gap-1">
|
||||
<span className="px-1 font-semibold">{label}</span>
|
||||
<span className="min-h-[18px] border-b border-dotted border-neutral-500 px-2 py-0.5 font-bold print:min-h-[14px] print:px-1">{value}</span>
|
||||
<span className="px-1 text-right font-semibold" dir="rtl">{arLabel}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-[160px] px-4 py-3 leading-7 print:min-h-[104px] print:px-2 print:py-1.5 print:leading-[1.55]">
|
||||
Prénom: ***** AUCUN ***** <span dir="rtl">لا أحد</span><br />
|
||||
Nom: *****<br />
|
||||
Date de naissance: *****<br />
|
||||
N° de C.I.N.: *****<br />
|
||||
N° de Permis: *****<br />
|
||||
Délivrée le: *****
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="w-full rounded-sm border border-neutral-950 bg-neutral-300 px-4 py-2.5 text-center text-base font-bold underline print:rounded-none print:px-2 print:py-1.5 print:text-[9px]">
|
||||
Le client est seul responsable des délits, contraventions et infractions au code de la route.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-1.5 print:space-y-1">
|
||||
<section className="w-full overflow-hidden rounded-sm border border-neutral-950 print:rounded-none">
|
||||
<div className="border-b border-neutral-950 bg-neutral-200 px-2 py-1.5 text-center font-serif text-base font-bold print:px-1.5 print:py-1 print:text-[9.5px]">Véhicule</div>
|
||||
<div className="space-y-1 p-2.5 print:space-y-0.5 print:p-1.5">
|
||||
{[
|
||||
['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]) => (
|
||||
<div key={label} className="grid min-h-7 grid-cols-[42%_58%] items-center gap-1.5 print:min-h-[18px] print:gap-1">
|
||||
<span className="px-1 font-bold">{label}</span>
|
||||
<span className="min-h-[18px] border-b border-dotted border-neutral-500 px-2 py-0.5 font-bold print:min-h-[14px] print:px-1">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid w-full grid-cols-2 gap-1.5 print:gap-1">
|
||||
<div className="overflow-hidden rounded-sm border border-neutral-950 text-center print:rounded-none">
|
||||
<div className="border-b border-neutral-950 bg-neutral-200 px-2 py-1 font-bold print:px-1.5">Roue de secours</div>
|
||||
<div className="px-2 py-1.5 text-[18px] leading-none print:px-1 print:py-0.5 print:text-[10px]">
|
||||
{checkbox(contract.vehicle.spareWheel)} OUI {checkbox(!contract.vehicle.spareWheel)} NON
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-sm border border-neutral-950 text-center print:rounded-none">
|
||||
<div className="border-b border-neutral-950 bg-neutral-200 px-2 py-1 font-bold print:px-1">Poste radio et CD</div>
|
||||
<div className="px-2 py-1.5 text-[18px] leading-none print:px-1 print:py-0.5 print:text-[10px]">
|
||||
{checkbox(contract.vehicle.radioCd)} OUI {checkbox(!contract.vehicle.radioCd)} NON
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full grid-cols-[30fr_70fr] overflow-hidden rounded-sm border border-neutral-950 print:rounded-none">
|
||||
<div className="flex items-center justify-center border-r border-neutral-950 bg-neutral-200 px-2 text-center font-bold print:px-1">Niveau carburant</div>
|
||||
<div className="grid grid-cols-5 gap-1 p-1.5 print:p-1">
|
||||
{fuelOptions.map((option) => (
|
||||
<div key={option.value} className="min-h-8 border-b border-neutral-950 px-1 text-center print:min-h-[21px]">
|
||||
<span className="block font-bold">{option.label}</span>
|
||||
<b className="block text-sm leading-none">{isFuelSelected(option.value, option.label) ? 'X' : ''}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="w-full overflow-hidden rounded-sm border border-neutral-950 print:rounded-none">
|
||||
<div className="border-b border-neutral-950 bg-neutral-200 px-2 py-1.5 text-center font-serif text-base font-bold print:px-1.5 print:py-1 print:text-[9.5px]">État général à la réception du client</div>
|
||||
<div className="flex justify-center px-3 py-2 print:px-1.5 print:py-1">
|
||||
<VehicleConditionSheet points={damagePoints} className="w-full max-w-[430px] bg-white p-1.5 print:max-w-[268px] print:p-1" />
|
||||
</div>
|
||||
<p className="px-3 pb-2 text-xs text-neutral-700 print:px-1.5 print:pb-1 print:text-[6.8px]">
|
||||
{damagePoints.length > 0 ? copy.damageMarkersRecorded(damagePoints.length) : copy.noDamage}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-stone-500 px-6 py-4 text-center text-sm text-stone-700">
|
||||
{bl(copy.roadsideAssistance, arCopy.roadsideAssistance)} <span className="font-semibold text-stone-900">{roadsidePhone}</span>
|
||||
</div>
|
||||
<section className="mt-1.5 grid min-h-[124px] w-full grid-cols-2 overflow-hidden rounded-sm border border-neutral-950 print:mt-1 print:min-h-[78px] print:rounded-none">
|
||||
<div className="px-4 py-3 text-center font-bold underline print:px-2 print:py-1.5">
|
||||
Signature du client : lu et approuvé des conditions
|
||||
</div>
|
||||
<div className="border-l border-neutral-950 px-4 py-3 text-center font-bold underline print:px-2 print:py-1.5">
|
||||
Cachet et signature de la société
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="mt-1 border-t border-neutral-500 px-1 pt-1 font-semibold">
|
||||
Adresse : {companyAddress}
|
||||
<div>
|
||||
Contact : {[contract.company.phone, contract.company.email].filter(Boolean).join(' · ') || copy.none}
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-[1060px] rounded-[28px] border-4 border-stone-900 bg-white px-8 py-7 text-stone-900 shadow-2xl print:max-w-none print:shadow-none">
|
||||
<div className="border-b border-stone-400 pb-4 text-center">
|
||||
<h2 className="text-2xl font-black tracking-tight">{bl(copy.termsAndConditions, arCopy.termsAndConditions)}</h2>
|
||||
<p className="mt-1 text-sm text-stone-600">
|
||||
{contract.company.name} · {contract.contractNumber ?? copy.draftAgreement}
|
||||
</p>
|
||||
<section className="mx-auto max-w-[1060px] rounded-[28px] border-4 border-stone-900 bg-white px-8 py-7 text-stone-900 shadow-2xl print:max-w-none print:rounded-none print:border-2 print:px-4 print:py-3 print:shadow-none">
|
||||
<div className="grid grid-cols-[160px_1fr_160px] items-start border-b border-stone-400 pb-4 print:grid-cols-[92px_1fr_92px] print:pb-2">
|
||||
<div className="text-left">
|
||||
<img src={companyLogoSrc} alt={companyName} className="mb-1 h-10 max-w-[120px] object-contain object-left print:h-6 print:max-w-[76px]" />
|
||||
<p className="text-sm font-semibold italic text-stone-900 print:text-[7px]">{companyName}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-black tracking-tight print:text-[12px]">
|
||||
<span className="block whitespace-nowrap">{frCopy.termsAndConditions}</span>
|
||||
<span className="block" dir="rtl">{arCopy.termsAndConditions}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
{conditionColumns.length === 2 ? (
|
||||
<div dir="ltr" className="mt-6 grid grid-cols-2 divide-x divide-stone-400 print:grid-cols-2">
|
||||
{conditionColumns.map((column) => (
|
||||
<div key={column.language} dir={column.language === 'ar' ? 'rtl' : 'ltr'} className="space-y-5 px-6">
|
||||
<div className="border-b border-stone-300 pb-2">
|
||||
<p className="text-lg font-bold text-stone-900">{column.heading}</p>
|
||||
<div dir="ltr" className="mt-4 border-b border-stone-300 pb-4 print:mt-2 print:pb-2">
|
||||
<div className="grid grid-cols-2 divide-x divide-stone-300">
|
||||
<div className="pr-5 print:pr-2">
|
||||
<p className="text-base font-black text-stone-900 print:text-[8px]">Politiques de location</p>
|
||||
<div className="mt-2 grid grid-cols-1 gap-1.5 print:mt-1 print:gap-0.5">
|
||||
{rentalPolicyItems.map((policy) => (
|
||||
<div key={`fr-${policy.key}`} className="grid grid-cols-[132px_1fr] gap-2 border-b border-stone-200 pb-1.5 text-xs leading-5 last:border-b-0 print:grid-cols-[72px_1fr] print:gap-1 print:pb-1 print:text-[5.4px] print:leading-[1.08]">
|
||||
<p className="font-bold text-stone-900">{policy.frLabel}</p>
|
||||
<p className="whitespace-pre-wrap text-stone-700">{policy.frValue || frCopy.none}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div dir="rtl" className="pl-5 print:pl-2">
|
||||
<p className="text-base font-black text-stone-900 print:text-[8px]">سياسات الكراء</p>
|
||||
<div className="mt-2 grid grid-cols-1 gap-1.5 print:mt-1 print:gap-0.5">
|
||||
{rentalPolicyItems.map((policy) => (
|
||||
<div key={`ar-${policy.key}`} className="grid grid-cols-[132px_1fr] gap-2 border-b border-stone-200 pb-1.5 text-xs leading-5 last:border-b-0 print:grid-cols-[72px_1fr] print:gap-1 print:pb-1 print:text-[5.4px] print:leading-[1.08]">
|
||||
<p className="font-bold text-stone-900">{policy.arLabel}</p>
|
||||
<p className="whitespace-pre-wrap text-stone-700">{policy.arValue || arCopy.none}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visibleConditionColumns.length === 2 ? (
|
||||
<div dir="ltr" className="mt-4 grid grid-cols-2 divide-x divide-stone-400 print:mt-2 print:grid-cols-2">
|
||||
{visibleConditionColumns.map((column) => (
|
||||
<div key={column.language} dir={column.language === 'ar' ? 'rtl' : 'ltr'} className="space-y-5 px-6 print:space-y-1.5 print:px-2">
|
||||
<div className="border-b border-stone-300 pb-2 print:pb-1">
|
||||
<p className="text-lg font-bold text-stone-900 print:text-[9px]">{column.heading}</p>
|
||||
</div>
|
||||
{column.clauses.map((clause, index) => (
|
||||
<div key={`${column.language}-${index}-${clause.title}`} className="text-sm leading-6 text-stone-700">
|
||||
<div key={`${column.language}-${index}-${clause.title}`} className="text-sm leading-6 text-stone-700 print:text-[6.8px] print:leading-[1.12]">
|
||||
<p className="font-bold text-stone-900">{clause.title}</p>
|
||||
<p className="mt-2 whitespace-pre-wrap">{clause.body}</p>
|
||||
<p className="mt-2 whitespace-pre-wrap print:mt-0.5">{compactText(clause.body, copy.none, 520)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 grid grid-cols-2 divide-x divide-stone-400 print:grid-cols-2">
|
||||
{singleLanguageClauseColumns.map((columnClauses, columnIndex) => (
|
||||
<div key={`single-language-column-${columnIndex}`} dir={contractUiLanguage === 'ar' ? 'rtl' : 'ltr'} className="space-y-5 px-6">
|
||||
<div className="mt-6 grid grid-cols-2 divide-x divide-stone-400 print:mt-2 print:grid-cols-3">
|
||||
{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) => (
|
||||
<div key={`single-language-column-${columnIndex}`} dir={contractUiLanguage === 'ar' ? 'rtl' : 'ltr'} className="space-y-5 px-6 print:space-y-1.5 print:px-2">
|
||||
{columnClauses.map((clause, index) => (
|
||||
<div key={`${columnIndex}-${index}-${clause.title}`} className="text-sm leading-6 text-stone-700">
|
||||
<div key={`${columnIndex}-${index}-${clause.title}`} className="text-sm leading-6 text-stone-700 print:text-[7px] print:leading-[1.15]">
|
||||
<p className="font-bold text-stone-900">{clause.title}</p>
|
||||
<p className="mt-2 whitespace-pre-wrap">{clause.body}</p>
|
||||
<p className="mt-2 whitespace-pre-wrap print:mt-0.5">{compactText(clause.body, copy.none, 620)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<string, string>
|
||||
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<string, unknown> | 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<string, string>
|
||||
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<string, unknown> | 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<string, string>) {
|
||||
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<ReservationDetail | null>(null)
|
||||
const [billing, setBilling] = useState<ReservationBilling | null>(null)
|
||||
const [inspections, setInspections] = useState<DamageInspection[]>([])
|
||||
const [form, setForm] = useState<ReservationFormState | null>(null)
|
||||
const [editMode, setEditMode] = useState<EditMode>(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<ReservationDetail>(`/reservations/${params.id}`),
|
||||
apiFetch<DamageInspection[]>(`/reservations/${params.id}/inspections`),
|
||||
apiFetch<ReservationBilling>(`/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 <div className="card p-6 text-sm text-red-600">{error}</div>
|
||||
if (!reservation || !form) return <div className="card p-6 text-sm text-slate-500">{r.loadingReservation}</div>
|
||||
if (!reservation || !form || !billing) return <div className="card p-6 text-sm text-slate-500">{r.loadingReservation}</div>
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
@@ -494,6 +808,9 @@ export default function ReservationDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={`/contracts/${reservation.id}`} className="btn-secondary">
|
||||
{reservation.workflow.contractGenerated ? copy.openContract : copy.generateContract}
|
||||
</Link>
|
||||
{editMode === null && reservation.workflow.coreEditable && (
|
||||
<button disabled={acting} onClick={() => setEditMode('booking')} className="btn-secondary">
|
||||
{copy.editBooking}
|
||||
@@ -504,7 +821,7 @@ export default function ReservationDetailPage() {
|
||||
<button disabled={acting} onClick={() => saveReservation('booking')} className="btn-primary">
|
||||
{acting ? r.working : copy.saveBooking}
|
||||
</button>
|
||||
<button disabled={acting} onClick={() => { setForm(toFormState(reservation)); setEditMode(null) }} className="btn-secondary">
|
||||
<button disabled={acting} onClick={() => { setForm(toReservationFormState(reservation)); setEditMode(null) }} className="btn-secondary">
|
||||
{copy.cancelEdit}
|
||||
</button>
|
||||
</>
|
||||
@@ -524,7 +841,7 @@ export default function ReservationDetailPage() {
|
||||
<button disabled={acting} onClick={() => saveReservation('return')} className="btn-primary">
|
||||
{acting ? r.working : copy.saveReturn}
|
||||
</button>
|
||||
<button disabled={acting} onClick={() => { setForm(toFormState(reservation)); setEditMode(null) }} className="btn-secondary">
|
||||
<button disabled={acting} onClick={() => { setForm(toReservationFormState(reservation)); setEditMode(null) }} className="btn-secondary">
|
||||
{copy.cancelEdit}
|
||||
</button>
|
||||
</>
|
||||
@@ -723,6 +1040,55 @@ export default function ReservationDetailPage() {
|
||||
onChange={(e) => setForm((current) => current ? { ...current, paymentMode: e.target.value } : current)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-600 disabled:cursor-not-allowed"
|
||||
checked={form.spareWheel}
|
||||
disabled={bookingInputsDisabled}
|
||||
onChange={(e) => setForm((current) => current ? { ...current, spareWheel: e.target.checked } : current)}
|
||||
/>
|
||||
{copy.spareWheelLabel}
|
||||
</label>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-600 disabled:cursor-not-allowed"
|
||||
checked={form.radioCd}
|
||||
disabled={bookingInputsDisabled}
|
||||
onChange={(e) => setForm((current) => current ? { ...current, radioCd: e.target.checked } : current)}
|
||||
/>
|
||||
{copy.radioCdLabel}
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-6 rounded-2xl border border-slate-200 p-4">
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-slate-900">{copy.contractFieldsTitle}</h4>
|
||||
<p className="mt-1 text-xs text-slate-500">{copy.contractFieldsHint}</p>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
{CONTRACT_FIELD_GROUPS.map((group) => (
|
||||
<section key={group.key} className="space-y-3">
|
||||
<h5 className="text-xs font-semibold uppercase tracking-wide text-slate-500">{copy.contractFieldGroupTitles[group.key]}</h5>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{group.fields.map((field) => (
|
||||
<label key={field} className="space-y-1">
|
||||
<span className="text-xs font-medium text-slate-700">{copy.contractFieldLabels[field]}</span>
|
||||
<input
|
||||
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
|
||||
value={form.contractFields[field] ?? ''}
|
||||
disabled={bookingInputsDisabled}
|
||||
onChange={(e) => setForm((current) => current ? {
|
||||
...current,
|
||||
contractFields: { ...current.contractFields, [field]: e.target.value },
|
||||
} : current)}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.bookingNotesLabel}</label>
|
||||
@@ -734,13 +1100,63 @@ export default function ReservationDetailPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionAdditionalDrivers}</h3>
|
||||
<div className="space-y-3">
|
||||
{reservation.additionalDrivers.map((driver) => (
|
||||
<div key={driver.id} className="rounded-2xl border border-slate-200 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{driver.firstName} {driver.lastName}</p>
|
||||
<p className="text-sm text-slate-500">{r.driverLicenseLabel} {driver.driverLicense}</p>
|
||||
<p className="mt-1 text-sm text-slate-600">{r.driverChargeLabel} {formatCurrency(driver.totalCharge, 'MAD')}</p>
|
||||
</div>
|
||||
{driver.requiresApproval && !driver.approvedAt ? (
|
||||
<button onClick={() => approveDriver(driver.id)} disabled={acting} className="rounded-full bg-orange-600 px-4 py-2 text-xs font-semibold text-white">
|
||||
{r.approveBtn}
|
||||
</button>
|
||||
) : (
|
||||
<span className={driver.approvedAt ? 'badge-green' : 'badge-gray'}>
|
||||
{driver.approvedAt ? r.approvedBadge : r.noApprovalNeeded}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{driver.approvalNote && <p className="mt-2 text-xs text-orange-700">{driver.approvalNote}</p>}
|
||||
</div>
|
||||
))}
|
||||
{reservation.additionalDrivers.length === 0 && (
|
||||
<div className="text-sm text-slate-400">{r.noAdditionalDrivers}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionInspectionSummary}</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||
<span className="text-slate-600">{r.checkOutInspectionLabel}</span>
|
||||
<span className={checkoutInspection ? 'badge-green' : 'badge-gray'}>{checkoutInspection ? r.savedBadge : r.pendingBadge}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||
<span className="text-slate-600">{r.checkInInspectionLabel}</span>
|
||||
<span className={checkinInspection ? 'badge-green' : 'badge-gray'}>{checkinInspection ? r.savedBadge : r.pendingBadge}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||
<span className="text-slate-600">{copy.returnLabel}</span>
|
||||
<span className="font-medium text-slate-900">{reservation.returnLocation ?? copy.noLocation}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReservationPhotoSection
|
||||
reservationId={reservation.id}
|
||||
type="PICKUP"
|
||||
editable={reservation.workflow.checkInInspectionEditable}
|
||||
title={copy.pickupPhotosTitle}
|
||||
readOnlyMessage={copy.pickupPhotosReadOnly}
|
||||
type="DROPOFF"
|
||||
editable={reservation.workflow.checkOutInspectionEditable}
|
||||
title={copy.dropoffPhotosTitle}
|
||||
readOnlyMessage={copy.dropoffPhotosReadOnly}
|
||||
/>
|
||||
|
||||
<div className="card p-6">
|
||||
@@ -788,62 +1204,45 @@ export default function ReservationDetailPage() {
|
||||
|
||||
<ReservationPhotoSection
|
||||
reservationId={reservation.id}
|
||||
type="DROPOFF"
|
||||
editable={reservation.workflow.checkOutInspectionEditable}
|
||||
title={copy.dropoffPhotosTitle}
|
||||
readOnlyMessage={copy.dropoffPhotosReadOnly}
|
||||
type="PICKUP"
|
||||
editable={reservation.workflow.checkInInspectionEditable}
|
||||
title={copy.pickupPhotosTitle}
|
||||
readOnlyMessage={copy.pickupPhotosReadOnly}
|
||||
/>
|
||||
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCharges}</h3>
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div><dt className="text-slate-500">{r.chargeDiscount}</dt><dd className="text-slate-900">{formatCurrency(reservation.discountAmount, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">{r.chargeInsurance}</dt><dd className="text-slate-900">{formatCurrency(reservation.insuranceTotal, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">{r.chargeAdditionalDrivers}</dt><dd className="text-slate-900">{formatCurrency(reservation.additionalDriverTotal, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">{r.chargePricingAdjustments}</dt><dd className="text-slate-900">{formatCurrency(reservation.pricingRulesTotal, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">{r.chargeGrandTotal}</dt><dd className="font-semibold text-slate-900">{formatCurrency(reservation.totalAmount, 'MAD')}</dd></div>
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-200">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_64px_96px] gap-3 bg-slate-50 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<span>{chargeTableCopy.item}</span>
|
||||
<span className="text-right">{chargeTableCopy.qty}</span>
|
||||
<span className="text-right">{chargeTableCopy.total}</span>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-200">
|
||||
{billing.lineItems.map((item, index) => (
|
||||
<div key={`${item.category}-${item.description}-${index}`} className="grid grid-cols-[minmax(0,1fr)_64px_96px] gap-3 px-4 py-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{item.description}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{formatCurrency(item.unitPrice, 'MAD')} {chargeTableCopy.unitSuffix}</p>
|
||||
</div>
|
||||
<span className="text-right text-slate-600">{item.qty}</span>
|
||||
<span className={item.total < 0 ? 'text-right font-semibold text-emerald-700' : 'text-right font-semibold text-slate-900'}>
|
||||
{formatCurrency(item.total, 'MAD')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-slate-200 bg-slate-50 px-4 py-3 text-sm">
|
||||
<span className="font-semibold text-slate-900">{r.chargeGrandTotal}</span>
|
||||
<span className="font-semibold text-slate-900">{formatCurrency(billing.grandTotal, 'MAD')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div><dt className="text-slate-500">{copy.returnChargeLabel}</dt><dd className="text-slate-900">{formatCurrency(reservation.damageChargeAmount ?? 0, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">{r.chargeGrandTotal}</dt><dd className="font-semibold text-slate-900">{formatCurrency(reservation.totalAmount, 'MAD')}</dd></div>
|
||||
</dl>
|
||||
|
||||
{reservation.insurances.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<p className="mb-2 text-sm font-semibold text-slate-900">{r.appliedInsurance}</p>
|
||||
<div className="space-y-2">
|
||||
{reservation.insurances.map((insurance) => (
|
||||
<div key={insurance.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
|
||||
<span className="text-slate-700">{insurance.policyName}</span>
|
||||
<span className="font-semibold text-slate-900">{formatCurrency(insurance.totalCharge, 'MAD')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reservation.pricingRulesApplied && reservation.pricingRulesApplied.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<p className="mb-2 text-sm font-semibold text-slate-900">{r.pricingRulesApplied}</p>
|
||||
<div className="space-y-2">
|
||||
{reservation.pricingRulesApplied.map((rule) => (
|
||||
<div key={`${rule.name}-${rule.amount}`} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
|
||||
<span className="text-slate-700">{rule.name}</span>
|
||||
<span className={rule.amount < 0 ? 'font-semibold text-emerald-700' : 'font-semibold text-orange-700'}>
|
||||
{rule.amount < 0 ? '-' : '+'}{formatCurrency(Math.abs(rule.amount), 'MAD')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DamageInspectionCard
|
||||
reservationId={reservation.id}
|
||||
type="CHECKIN"
|
||||
initialInspection={checkinInspection}
|
||||
editable={reservation.workflow.checkInInspectionEditable}
|
||||
readOnlyMessage={checkInReadOnlyMessage}
|
||||
onSaved={(inspection) => setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])}
|
||||
/>
|
||||
<DamageInspectionCard
|
||||
reservationId={reservation.id}
|
||||
type="CHECKOUT"
|
||||
@@ -853,56 +1252,14 @@ export default function ReservationDetailPage() {
|
||||
readOnlyMessage={checkOutReadOnlyMessage}
|
||||
onSaved={(inspection) => setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionAdditionalDrivers}</h3>
|
||||
<div className="space-y-3">
|
||||
{reservation.additionalDrivers.map((driver) => (
|
||||
<div key={driver.id} className="rounded-2xl border border-slate-200 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{driver.firstName} {driver.lastName}</p>
|
||||
<p className="text-sm text-slate-500">{r.driverLicenseLabel} {driver.driverLicense}</p>
|
||||
<p className="mt-1 text-sm text-slate-600">{r.driverChargeLabel} {formatCurrency(driver.totalCharge, 'MAD')}</p>
|
||||
</div>
|
||||
{driver.requiresApproval && !driver.approvedAt ? (
|
||||
<button onClick={() => approveDriver(driver.id)} disabled={acting} className="rounded-full bg-orange-600 px-4 py-2 text-xs font-semibold text-white">
|
||||
{r.approveBtn}
|
||||
</button>
|
||||
) : (
|
||||
<span className={driver.approvedAt ? 'badge-green' : 'badge-gray'}>
|
||||
{driver.approvedAt ? r.approvedBadge : r.noApprovalNeeded}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{driver.approvalNote && <p className="mt-2 text-xs text-orange-700">{driver.approvalNote}</p>}
|
||||
</div>
|
||||
))}
|
||||
{reservation.additionalDrivers.length === 0 && (
|
||||
<div className="text-sm text-slate-400">{r.noAdditionalDrivers}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionInspectionSummary}</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||
<span className="text-slate-600">{r.checkInInspectionLabel}</span>
|
||||
<span className={checkinInspection ? 'badge-green' : 'badge-gray'}>{checkinInspection ? r.savedBadge : r.pendingBadge}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||
<span className="text-slate-600">{r.checkOutInspectionLabel}</span>
|
||||
<span className={checkoutInspection ? 'badge-green' : 'badge-gray'}>{checkoutInspection ? r.savedBadge : r.pendingBadge}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||
<span className="text-slate-600">{copy.returnLabel}</span>
|
||||
<span className="font-medium text-slate-900">{reservation.returnLocation ?? copy.noLocation}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DamageInspectionCard
|
||||
reservationId={reservation.id}
|
||||
type="CHECKIN"
|
||||
initialInspection={checkinInspection}
|
||||
editable={reservation.workflow.checkInInspectionEditable}
|
||||
readOnlyMessage={checkInReadOnlyMessage}
|
||||
onSaved={(inspection) => setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colDates}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colSource}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colStatus}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{contractColumnLabel}</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colTotal}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -171,12 +179,20 @@ export default function ReservationsPage() {
|
||||
<td className="px-6 py-4 text-sm text-slate-500">{formatDate(row.startDate)} - {formatDateYear(row.endDate)}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.source}</td>
|
||||
<td className="px-6 py-4"><span className="badge-blue">{row.status}</span></td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<Link href={`/contracts/${row.id}`} className="font-semibold text-blue-700 hover:underline">
|
||||
{contractActionLabel(row)}
|
||||
</Link>
|
||||
{row.contractNumber ? (
|
||||
<p className="mt-1 text-xs text-slate-500">{row.contractNumber}</p>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredRows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{r.noReservations}</td>
|
||||
<td colSpan={7} className="px-6 py-10 text-center text-sm text-slate-400">{r.noReservations}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
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 (
|
||||
<Suspense fallback={null}>
|
||||
<SignInPageClient embedded={embedded} />
|
||||
</Suspense>
|
||||
)
|
||||
for (const key of ['portal', 'embedded']) {
|
||||
const value = firstValue(params[key])
|
||||
if (value) destinationUrl.searchParams.set(key, value)
|
||||
}
|
||||
|
||||
redirect(`${destinationUrl.pathname}${destinationUrl.search}`)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-t border-stone-200 pt-6 text-center text-sm text-stone-500 dark:border-blue-900 dark:text-stone-400">
|
||||
<Link
|
||||
href="/sign-in"
|
||||
<a
|
||||
href={signInHref}
|
||||
className="font-semibold text-stone-900 underline decoration-stone-300 underline-offset-4 dark:text-stone-100 dark:decoration-stone-700"
|
||||
>
|
||||
{dict.backToLogin}
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<p className="mt-3 text-sm text-slate-500">
|
||||
Your invitation has been processed. You can now sign in to access the team dashboard.
|
||||
</p>
|
||||
<Link href="/sign-in" className="mt-8 btn-primary justify-center w-full">
|
||||
<a href={buildHomepageSignInPath()} className="mt-8 btn-primary justify-center w-full">
|
||||
Sign in to dashboard
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-stone-900 dark:text-stone-100">{dict.successTitle}</h2>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.successBody}</p>
|
||||
<Link href="/sign-in" className="btn-primary inline-flex justify-center">{dict.signIn}</Link>
|
||||
<a href={signInHref} className="btn-primary inline-flex justify-center">{dict.signIn}</a>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
@@ -216,9 +218,9 @@ function ResetPasswordContent({ embedded = false }: { embedded?: boolean }) {
|
||||
</form>
|
||||
|
||||
<div className="mt-6 border-t border-stone-200 pt-6 text-center text-sm text-stone-500 dark:border-blue-900 dark:text-stone-400">
|
||||
<Link href="/sign-in" className="font-semibold text-stone-900 underline decoration-stone-300 underline-offset-4 dark:text-stone-100 dark:decoration-stone-700">
|
||||
<a href={signInHref} className="font-semibold text-stone-900 underline decoration-stone-300 underline-offset-4 dark:text-stone-100 dark:decoration-stone-700">
|
||||
{dict.backToLogin}
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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 (
|
||||
<PublicShell embedded={embedded} hideFooter hideHeaderActions>
|
||||
<main className="flex flex-1 items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="flex justify-center">
|
||||
<a href={websiteUrl} target="_top">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
width={104}
|
||||
height={104}
|
||||
unoptimized
|
||||
className="h-24 w-24 rounded-[1.75rem] border border-stone-200/80 bg-white/85 p-1.5 shadow-sm transition-colors dark:border-blue-800 dark:bg-blue-950/80"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-stone-600 dark:text-stone-300">
|
||||
{dict.subtitle}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
{(["en", "fr", "ar"] as const).map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => setLanguage(lang)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wider transition-colors text-orange-700 dark:text-orange-300 ${
|
||||
language === lang
|
||||
? "ring-1 ring-current opacity-100"
|
||||
: "opacity-45 hover:opacity-80"
|
||||
}`}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="rounded-[2rem] border border-stone-200/80 bg-white/82 p-8 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:border-blue-900 dark:bg-blue-950/78 dark:shadow-[0_30px_80px_rgba(0,0,0,0.26)]">
|
||||
<LocalSignInForm dict={dict} />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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 ? (
|
||||
<div className="mb-5 rounded-[1.5rem] border border-red-200/80 bg-red-50/90 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
|
||||
<div>{error}</div>
|
||||
{canResendVerification ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendVerification}
|
||||
disabled={resendingVerification}
|
||||
className="mt-3 text-sm font-semibold underline decoration-red-300 underline-offset-4 disabled:cursor-not-allowed disabled:opacity-60 dark:decoration-red-700"
|
||||
>
|
||||
{resendingVerification ? dict.resendingVerification : dict.resendVerification}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === "credentials" ? (
|
||||
<form onSubmit={handleCredentials} className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
|
||||
{dict.email}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
|
||||
{dict.password}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex w-full justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300"
|
||||
>
|
||||
{loading ? dict.signingIn : dict.signIn}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-stone-500 underline decoration-stone-300 underline-offset-4 hover:text-stone-900 dark:text-stone-400 dark:decoration-stone-600 dark:hover:text-stone-100"
|
||||
>
|
||||
{dict.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleTotp} className="space-y-5">
|
||||
<div className="rounded-[1.5rem] border border-stone-200/80 bg-stone-50/90 px-4 py-4 text-center text-sm text-stone-600 dark:border-blue-800 dark:bg-blue-950/50 dark:text-stone-300">
|
||||
{dict.enterCode}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
|
||||
{dict.authCode}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="text"
|
||||
pattern="[0-9A-Za-z-]{6,14}"
|
||||
maxLength={14}
|
||||
required
|
||||
value={totpCode}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex w-full justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300"
|
||||
>
|
||||
{loading ? dict.verifying : dict.verify}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("credentials");
|
||||
setTotpCode("");
|
||||
setError(null);
|
||||
}}
|
||||
className="w-full text-sm text-stone-500 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100"
|
||||
>
|
||||
{dict.back}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>) {
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
<h1 className="text-xl font-black text-blue-950 dark:text-stone-50">{dict.success}</h1>
|
||||
<a
|
||||
href="/sign-in"
|
||||
href={signInHref}
|
||||
className="mt-6 inline-flex justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
|
||||
>
|
||||
{dict.signIn}
|
||||
@@ -132,7 +134,7 @@ export default function VerifyEmailPage() {
|
||||
<p className="mt-2 text-xs text-stone-500 dark:text-stone-400 font-mono break-all">{errorDetail}</p>
|
||||
)}
|
||||
<a
|
||||
href="/sign-in"
|
||||
href={signInHref}
|
||||
className="mt-6 inline-flex justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
|
||||
>
|
||||
{dict.signIn}
|
||||
|
||||
@@ -39,6 +39,7 @@ describe('DashboardAccessGuard route helpers', () => {
|
||||
expect(resolveAllowedRoutes({ items: [], subscriptionAccessLevel: 'full' }, 'OWNER')).toEqual([
|
||||
'/',
|
||||
'/reservations',
|
||||
'/contracts',
|
||||
'/fleet',
|
||||
'/customers',
|
||||
'/reports',
|
||||
@@ -59,6 +60,7 @@ describe('DashboardAccessGuard route helpers', () => {
|
||||
}, 'MANAGER')).toEqual([
|
||||
'/',
|
||||
'/reservations',
|
||||
'/contracts',
|
||||
'/fleet',
|
||||
'/customers',
|
||||
'/reports',
|
||||
@@ -86,6 +88,7 @@ describe('DashboardAccessGuard route helpers', () => {
|
||||
}, 'OWNER')).toEqual([
|
||||
'/',
|
||||
'/reservations',
|
||||
'/contracts',
|
||||
'/fleet',
|
||||
'/customers',
|
||||
'/reports',
|
||||
@@ -94,13 +97,36 @@ describe('DashboardAccessGuard route helpers', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('allows contract detail routes whenever reservations are available in a generated menu', () => {
|
||||
const routes = resolveAllowedRoutes({
|
||||
subscriptionAccessLevel: 'full',
|
||||
items: [
|
||||
{
|
||||
id: 'dashboard',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '/',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'reservations',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '/reservations',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
}, 'AGENT')
|
||||
|
||||
expect(routes).toEqual(['/', '/reservations', '/contracts'])
|
||||
expect(resolveAccessRedirect('/contracts/reservation_1', routes)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not apply baseline fallback when subscription access is none', () => {
|
||||
expect(resolveAllowedRoutes({ items: [], subscriptionAccessLevel: 'none' }, 'OWNER')).toEqual([])
|
||||
})
|
||||
|
||||
it('filters baseline routes by role', () => {
|
||||
expect(getBaselineInternalRoutes('AGENT')).toEqual(['/', '/reservations', '/fleet', '/customers'])
|
||||
expect(getBaselineInternalRoutes('MANAGER')).toEqual(['/', '/reservations', '/fleet', '/customers', '/reports', '/billing'])
|
||||
expect(getBaselineInternalRoutes('AGENT')).toEqual(['/', '/reservations', '/contracts', '/fleet', '/customers'])
|
||||
expect(getBaselineInternalRoutes('MANAGER')).toEqual(['/', '/reservations', '/contracts', '/fleet', '/customers', '/reports', '/billing'])
|
||||
})
|
||||
|
||||
it('redirects disallowed routes to the first visible internal route', () => {
|
||||
@@ -109,8 +135,8 @@ describe('DashboardAccessGuard route helpers', () => {
|
||||
})
|
||||
|
||||
it('builds sign-in redirects with public dashboard return paths', () => {
|
||||
expect(buildSignInRedirect('/reservations')).toBe('/dashboard/sign-in?redirect=%2Fdashboard%2Freservations')
|
||||
expect(buildSignInRedirect('/dashboard/fleet')).toBe('/dashboard/sign-in?redirect=%2Fdashboard%2Ffleet')
|
||||
expect(buildSignInRedirect('/reservations')).toBe('/en/light/sign-in?redirect=%2Fdashboard%2Freservations')
|
||||
expect(buildSignInRedirect('/dashboard/fleet')).toBe('/en/light/sign-in?redirect=%2Fdashboard%2Ffleet')
|
||||
})
|
||||
|
||||
it('treats subscription as an owner-only recovery route independent of menu registration', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { toDashboardAppPath, toPublicDashboardPath } from '@/lib/dashboardPaths'
|
||||
import { buildHomepageSignInPath, toDashboardAppPath, toPublicDashboardPath } from '@/lib/dashboardPaths'
|
||||
import {
|
||||
getDashboardFallbackRoute,
|
||||
resolveDashboardRoutePolicy,
|
||||
@@ -32,6 +32,7 @@ const ROLE_RANK: Record<string, number> = { OWNER: 3, MANAGER: 2, AGENT: 1 }
|
||||
const BASELINE_MENU_ROUTES = [
|
||||
{ route: '/', minRole: 'AGENT' },
|
||||
{ route: '/reservations', minRole: 'AGENT' },
|
||||
{ route: '/contracts', minRole: 'AGENT' },
|
||||
{ route: '/fleet', minRole: 'AGENT' },
|
||||
{ route: '/customers', minRole: 'AGENT' },
|
||||
{ route: '/reports', minRole: 'MANAGER' },
|
||||
@@ -72,7 +73,11 @@ export function resolveAllowedRoutes(menu: EmployeeMenuResponse, role: string):
|
||||
menu.subscriptionAccessLevel !== 'none' &&
|
||||
featureRoutes.length === 0
|
||||
|
||||
return shouldUseBaselineFallback ? getBaselineInternalRoutes(role) : routes
|
||||
const resolvedRoutes = shouldUseBaselineFallback ? getBaselineInternalRoutes(role) : routes
|
||||
if (resolvedRoutes.includes('/reservations') && !resolvedRoutes.includes('/contracts')) {
|
||||
return [...resolvedRoutes, '/contracts']
|
||||
}
|
||||
return resolvedRoutes
|
||||
}
|
||||
|
||||
export function isAllowedRoute(currentPath: string, allowedRoutes: string[]) {
|
||||
@@ -91,9 +96,7 @@ export function resolveAccessRedirect(currentPath: string, allowedRoutes: string
|
||||
}
|
||||
|
||||
export function buildSignInRedirect(currentPath: string) {
|
||||
const params = new URLSearchParams()
|
||||
params.set('redirect', toPublicDashboardPath(currentPath))
|
||||
return `${toPublicDashboardPath('/sign-in')}?${params.toString()}`
|
||||
return buildHomepageSignInPath(currentPath)
|
||||
}
|
||||
|
||||
export default function DashboardAccessGuard({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -90,6 +90,7 @@ function toSidebarUser(profile: Partial<EmployeeProfile>, fallbackName: string,
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/', key: 'dashboard', icon: LayoutDashboard, exact: true, minRole: 'AGENT' },
|
||||
{ href: '/reservations', key: 'reservations', icon: Calendar, minRole: 'AGENT' },
|
||||
{ href: '/contracts', key: 'contracts', icon: FileText, minRole: 'AGENT' },
|
||||
{ href: '/fleet', key: 'fleet', icon: Car, minRole: 'AGENT' },
|
||||
{ href: '/customers', key: 'customers', icon: Users, minRole: 'AGENT' },
|
||||
{ href: '/reports', key: 'reports', icon: BarChart2, minRole: 'MANAGER' },
|
||||
|
||||
@@ -74,6 +74,8 @@ export function ReservationReview({
|
||||
rows: [
|
||||
[copy.deposit, display(draft.payment.depositAmount, copy.summaryEmpty)],
|
||||
[copy.paymentMode, copy.paymentModes[draft.payment.paymentMode] ?? draft.payment.paymentMode],
|
||||
[copy.spareWheel, draft.payment.spareWheel ? copy.yes : copy.no],
|
||||
[copy.radioCd, draft.payment.radioCd ? copy.yes : copy.no],
|
||||
[copy.additionalDriverInfo, draft.additionalDrivers.length ? String(draft.additionalDrivers.length) : copy.summaryEmpty],
|
||||
[copy.notes, display(draft.payment.notes, copy.summaryEmpty)],
|
||||
],
|
||||
|
||||
@@ -143,7 +143,7 @@ export function ReservationWizard({
|
||||
clearFieldError(`rental.${field}`)
|
||||
}
|
||||
|
||||
function updatePayment(field: keyof ReservationDraft['payment'], value: string) {
|
||||
function updatePayment(field: keyof ReservationDraft['payment'], value: string | boolean) {
|
||||
dispatch({ type: 'updatePayment', field, value })
|
||||
clearFieldError(`payment.${field}`)
|
||||
}
|
||||
@@ -617,7 +617,7 @@ function PaymentStep({
|
||||
copy: ReturnType<typeof getReservationWizardCopy>
|
||||
draft: ReservationDraft
|
||||
errors: FieldErrors
|
||||
onPaymentChange: (field: keyof ReservationDraft['payment'], value: string) => void
|
||||
onPaymentChange: (field: keyof ReservationDraft['payment'], value: string | boolean) => void
|
||||
onAddDriver: () => void
|
||||
onRemoveDriver: (id: string) => void
|
||||
onDriverChange: (id: string, field: keyof AdditionalDriverDraft, value: any) => void
|
||||
@@ -638,6 +638,26 @@ function PaymentStep({
|
||||
<span className="text-sm font-medium text-slate-700">{copy.notes}</span>
|
||||
<textarea value={draft.payment.notes} onChange={(event) => onPaymentChange('notes', event.target.value)} className="input-field min-h-[96px]" placeholder={copy.notesPlaceholder} />
|
||||
</label>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<label className="flex items-center gap-3 rounded-lg border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-600"
|
||||
checked={draft.payment.spareWheel}
|
||||
onChange={(event) => onPaymentChange('spareWheel', event.target.checked)}
|
||||
/>
|
||||
{copy.spareWheel}
|
||||
</label>
|
||||
<label className="flex items-center gap-3 rounded-lg border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-600"
|
||||
checked={draft.payment.radioCd}
|
||||
onChange={(event) => onPaymentChange('radioCd', event.target.checked)}
|
||||
/>
|
||||
{copy.radioCd}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
@@ -58,6 +58,10 @@ export function getReservationWizardCopy(language: Language) {
|
||||
availabilityConflict: 'The selected vehicle is not available for those dates.',
|
||||
deposit: 'Deposit amount (MAD)',
|
||||
paymentMode: 'Payment mode',
|
||||
spareWheel: 'Spare wheel',
|
||||
radioCd: 'Radio and CD',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
notes: 'Notes',
|
||||
notesPlaceholder: 'Optional notes...',
|
||||
additionalDriverInfo: 'Additional driver',
|
||||
@@ -141,6 +145,10 @@ export function getReservationWizardCopy(language: Language) {
|
||||
availabilityConflict: 'Le véhicule sélectionné n’est pas disponible pour ces dates.',
|
||||
deposit: 'Montant du dépôt (MAD)',
|
||||
paymentMode: 'Mode de paiement',
|
||||
spareWheel: 'Roue de secours',
|
||||
radioCd: 'Poste radio et CD',
|
||||
yes: 'Oui',
|
||||
no: 'Non',
|
||||
notes: 'Notes',
|
||||
notesPlaceholder: 'Notes optionnelles...',
|
||||
additionalDriverInfo: 'Conducteur supplémentaire',
|
||||
@@ -224,6 +232,10 @@ export function getReservationWizardCopy(language: Language) {
|
||||
availabilityConflict: 'المركبة المحددة غير متاحة في هذه التواريخ.',
|
||||
deposit: 'مبلغ العربون (MAD)',
|
||||
paymentMode: 'طريقة الدفع',
|
||||
spareWheel: 'العجلة الاحتياطية',
|
||||
radioCd: 'الراديو و CD',
|
||||
yes: 'نعم',
|
||||
no: 'لا',
|
||||
notes: 'ملاحظات',
|
||||
notesPlaceholder: 'ملاحظات اختيارية...',
|
||||
additionalDriverInfo: 'السائق الإضافي',
|
||||
@@ -266,4 +278,3 @@ export const wizardSteps: Array<{ id: WizardStepId; labelKey: keyof ReservationW
|
||||
{ id: 'payment', labelKey: 'payment' },
|
||||
{ id: 'review', labelKey: 'review' },
|
||||
]
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ export function createInitialDraft(): ReservationDraft {
|
||||
payment: {
|
||||
depositAmount: '0',
|
||||
paymentMode: 'CASH',
|
||||
spareWheel: false,
|
||||
radioCd: false,
|
||||
notes: '',
|
||||
},
|
||||
additionalDrivers: [],
|
||||
@@ -75,7 +77,7 @@ export type ReservationDraftAction =
|
||||
| { type: 'updateIdentity'; field: keyof ReservationDraft['identity']; value: string }
|
||||
| { type: 'updateLicense'; field: keyof ReservationDraft['license']; value: any }
|
||||
| { type: 'updateRental'; field: keyof ReservationDraft['rental']; value: string }
|
||||
| { type: 'updatePayment'; field: keyof ReservationDraft['payment']; value: string }
|
||||
| { type: 'updatePayment'; field: keyof ReservationDraft['payment']; value: string | boolean }
|
||||
| { type: 'addAdditionalDriver' }
|
||||
| { type: 'removeAdditionalDriver'; id: string }
|
||||
| { type: 'updateAdditionalDriver'; id: string; field: keyof AdditionalDriverDraft; value: any }
|
||||
|
||||
@@ -45,6 +45,8 @@ function draft(): ReservationDraft {
|
||||
payment: {
|
||||
depositAmount: '0',
|
||||
paymentMode: 'CASH',
|
||||
spareWheel: false,
|
||||
radioCd: false,
|
||||
notes: '',
|
||||
},
|
||||
additionalDrivers: [],
|
||||
@@ -66,6 +68,23 @@ describe('submitReservationDraft', () => {
|
||||
|
||||
expect(resolvedCustomerIds).toEqual(['customer_1'])
|
||||
expect(api).toHaveBeenCalledTimes(3)
|
||||
expect(JSON.parse(String(api.mock.calls[2][1]?.body))).toMatchObject({
|
||||
contractFields: {
|
||||
driverFirstName: 'Sara',
|
||||
driverLastName: 'Alaoui',
|
||||
driverBirthDate: '01/01/1990',
|
||||
driverNationality: 'Moroccan',
|
||||
driverAddress: '12 Main Street',
|
||||
driverPhone: '+212600000000',
|
||||
driverCin: 'AB123',
|
||||
driverLicense: 'DL-123',
|
||||
driverLicenseIssuedAt: '01/01/2020',
|
||||
driverLicenseExpiry: '01/01/2099',
|
||||
vehicleDeparture: '01/07/2026 10:00',
|
||||
vehicleReturn: '03/07/2026 10:00',
|
||||
vehicleDuration: '2 days',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a previously created customer on retry', async () => {
|
||||
|
||||
@@ -8,6 +8,29 @@ function isoFromLocal(value: string) {
|
||||
return new Date(value).toISOString()
|
||||
}
|
||||
|
||||
function formatDateForContract(value: string) {
|
||||
if (!value) return ''
|
||||
const [date] = value.split('T')
|
||||
const [year, month, day] = date.split('-')
|
||||
return year && month && day ? `${day}/${month}/${year}` : ''
|
||||
}
|
||||
|
||||
function formatDateTimeForContract(value: string) {
|
||||
if (!value) return ''
|
||||
const [date, time = ''] = value.split('T')
|
||||
const [year, month, day] = date.split('-')
|
||||
const [hour = '00', minute = '00'] = time.split(':')
|
||||
return year && month && day ? `${day}/${month}/${year} ${hour}:${minute}` : ''
|
||||
}
|
||||
|
||||
function compactFields(fields: Record<string, string>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(fields)
|
||||
.map(([key, value]) => [key, value.trim()])
|
||||
.filter(([, value]) => value),
|
||||
)
|
||||
}
|
||||
|
||||
export async function checkVehicleAvailability(draft: ReservationDraft) {
|
||||
const { vehicleId, startDate, endDate } = draft.rental
|
||||
const params = new URLSearchParams({
|
||||
@@ -68,6 +91,35 @@ function customerPatchPayload(draft: ReservationDraft) {
|
||||
}
|
||||
}
|
||||
|
||||
function contractFieldsPayload(draft: ReservationDraft) {
|
||||
const firstAdditionalDriver = draft.additionalDrivers[0]
|
||||
|
||||
return compactFields({
|
||||
driverFirstName: bilingualPrimary(draft.customer.firstName),
|
||||
driverLastName: bilingualPrimary(draft.customer.lastName),
|
||||
driverBirthDate: formatDateForContract(draft.identity.dateOfBirth),
|
||||
driverNationality: draft.identity.nationality,
|
||||
driverAddress: draft.identity.fullAddress,
|
||||
driverPhone: draft.customer.phone,
|
||||
driverCin: draft.identity.identityDocumentNumber,
|
||||
driverPassport: draft.identity.internationalLicenseNumber,
|
||||
driverLicense: draft.license.number,
|
||||
driverLicenseIssuedAt: formatDateForContract(draft.license.issuedAt),
|
||||
driverLicenseExpiry: formatDateForContract(draft.license.expiry),
|
||||
secondDriverFirstName: firstAdditionalDriver ? bilingualPrimary(firstAdditionalDriver.firstName) : '',
|
||||
secondDriverLastName: firstAdditionalDriver ? bilingualPrimary(firstAdditionalDriver.lastName) : '',
|
||||
secondDriverBirthDate: firstAdditionalDriver?.dateOfBirth ? formatDateForContract(firstAdditionalDriver.dateOfBirth) : '',
|
||||
secondDriverNationality: firstAdditionalDriver ? bilingualPrimary(firstAdditionalDriver.nationality) : '',
|
||||
secondDriverPhone: firstAdditionalDriver?.phone ?? '',
|
||||
secondDriverLicense: firstAdditionalDriver?.driverLicense ?? '',
|
||||
secondDriverLicenseIssuedAt: firstAdditionalDriver?.licenseIssuedAt ? formatDateForContract(firstAdditionalDriver.licenseIssuedAt) : '',
|
||||
secondDriverLicenseExpiry: firstAdditionalDriver?.licenseExpiry ? formatDateForContract(firstAdditionalDriver.licenseExpiry) : '',
|
||||
vehicleDeparture: formatDateTimeForContract(draft.rental.startDate),
|
||||
vehicleReturn: formatDateTimeForContract(draft.rental.endDate),
|
||||
vehicleDuration: `${Math.max(1, Math.ceil((new Date(draft.rental.endDate).getTime() - new Date(draft.rental.startDate).getTime()) / (1000 * 60 * 60 * 24)))} days`,
|
||||
})
|
||||
}
|
||||
|
||||
function reservationPayload(draft: ReservationDraft, customerId: string) {
|
||||
return {
|
||||
customerId,
|
||||
@@ -78,6 +130,9 @@ function reservationPayload(draft: ReservationDraft, customerId: string) {
|
||||
returnLocation: draft.rental.returnLocation.trim() || undefined,
|
||||
depositAmount: Number.isFinite(Number(draft.payment.depositAmount)) ? Math.max(0, Math.round(Number(draft.payment.depositAmount))) : 0,
|
||||
paymentMode: draft.payment.paymentMode,
|
||||
spareWheel: draft.payment.spareWheel,
|
||||
radioCd: draft.payment.radioCd,
|
||||
contractFields: contractFieldsPayload(draft),
|
||||
additionalDrivers: draft.additionalDrivers.map((driver) => ({
|
||||
firstName: bilingualPrimary(driver.firstName).trim(),
|
||||
firstNameAr: driver.firstName.ar.trim() || undefined,
|
||||
|
||||
@@ -50,6 +50,8 @@ function validDraft() {
|
||||
payment: {
|
||||
depositAmount: '0',
|
||||
paymentMode: 'CASH',
|
||||
spareWheel: false,
|
||||
radioCd: false,
|
||||
notes: '',
|
||||
},
|
||||
additionalDrivers: [],
|
||||
|
||||
@@ -78,6 +78,8 @@ export type ReservationDraft = {
|
||||
payment: {
|
||||
depositAmount: string
|
||||
paymentMode: string
|
||||
spareWheel: boolean
|
||||
radioCd: boolean
|
||||
notes: string
|
||||
}
|
||||
additionalDrivers: AdditionalDriverDraft[]
|
||||
@@ -88,4 +90,3 @@ export type WizardStepId = 'customer' | 'identity' | 'license' | 'rental' | 'pay
|
||||
export type FieldErrors = Record<string, string>
|
||||
|
||||
export type CreatedReservation = { id: string }
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toDashboardAppPath, toPublicDashboardPath } from './dashboardPaths'
|
||||
import { buildHomepageSignInPath, toDashboardAppPath, toPublicDashboardPath } from './dashboardPaths'
|
||||
|
||||
describe('dashboard path normalization', () => {
|
||||
it('collapses empty and root values to the app root', () => {
|
||||
@@ -19,4 +19,10 @@ describe('dashboard path normalization', () => {
|
||||
expect(toPublicDashboardPath('/dashboard/fleet')).toBe('/dashboard/fleet')
|
||||
expect(toPublicDashboardPath('customers')).toBe('/dashboard/customers')
|
||||
})
|
||||
|
||||
it('builds the canonical homepage sign-in URL with a dashboard return path', () => {
|
||||
expect(buildHomepageSignInPath('/reservations')).toBe('/en/light/sign-in?redirect=%2Fdashboard%2Freservations')
|
||||
expect(buildHomepageSignInPath('/dashboard/fleet', { locale: 'fr', theme: 'dark' })).toBe('/fr/dark/sign-in?redirect=%2Fdashboard%2Ffleet')
|
||||
expect(buildHomepageSignInPath()).toBe('/en/light/sign-in')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const DASHBOARD_BASE_PATH = '/dashboard'
|
||||
const DEFAULT_SIGN_IN_LOCALE = 'en'
|
||||
const DEFAULT_SIGN_IN_THEME = 'light'
|
||||
|
||||
export function toDashboardAppPath(path?: string | null): string {
|
||||
const value = (path ?? '').trim()
|
||||
@@ -17,3 +19,21 @@ export function toPublicDashboardPath(path?: string | null): string {
|
||||
const appPath = toDashboardAppPath(path)
|
||||
return appPath === '/' ? DASHBOARD_BASE_PATH : `${DASHBOARD_BASE_PATH}${appPath}`
|
||||
}
|
||||
|
||||
export function buildHomepageSignInPath(
|
||||
currentPath?: string | null,
|
||||
options: { locale?: string | null; theme?: string | null } = {},
|
||||
): string {
|
||||
const locale = options.locale === 'fr' || options.locale === 'ar' || options.locale === 'en'
|
||||
? options.locale
|
||||
: DEFAULT_SIGN_IN_LOCALE
|
||||
const theme = options.theme === 'dark' || options.theme === 'light'
|
||||
? options.theme
|
||||
: DEFAULT_SIGN_IN_THEME
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (currentPath) params.set('redirect', toPublicDashboardPath(currentPath))
|
||||
|
||||
const query = params.toString()
|
||||
return `/${locale}/${theme}/sign-in${query ? `?${query}` : ''}`
|
||||
}
|
||||
|
||||
@@ -72,12 +72,12 @@ describe('dashboard middleware', () => {
|
||||
expect(response).toEqual({ kind: 'response', body: 'Unsupported internal request header', status: 400 })
|
||||
})
|
||||
|
||||
it('redirects unauthenticated protected internal-host requests to dashboard sign-in on the resolved origin', async () => {
|
||||
it('redirects unauthenticated protected internal-host requests to homepage sign-in on the website origin', async () => {
|
||||
const { default: middleware } = await loadMiddleware('https://rentaldrivego.example')
|
||||
|
||||
const response = middleware(request('http://dashboard:3001/dashboard/team') as never)
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/dashboard/sign-in?redirect=%2Fdashboard%2Fteam' })
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/en/light/sign-in?redirect=%2Fdashboard%2Fteam' })
|
||||
})
|
||||
|
||||
it('redirects unprefixed internal app paths with a public dashboard return path', async () => {
|
||||
@@ -85,7 +85,7 @@ describe('dashboard middleware', () => {
|
||||
|
||||
const response = middleware(request('http://dashboard:3001/reservations') as never)
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/dashboard/sign-in?redirect=%2Fdashboard%2Freservations' })
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/en/light/sign-in?redirect=%2Fdashboard%2Freservations' })
|
||||
})
|
||||
|
||||
it('ignores spoofed forwarded host/proto when building the dashboard sign-in redirect', async () => {
|
||||
@@ -98,7 +98,7 @@ describe('dashboard middleware', () => {
|
||||
},
|
||||
}) as never)
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/dashboard/sign-in?redirect=%2Fdashboard%2Fbilling' })
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/en/light/sign-in?redirect=%2Fdashboard%2Fbilling' })
|
||||
})
|
||||
|
||||
it('ignores internal forwarded hosts when building the dashboard sign-in redirect', async () => {
|
||||
@@ -111,7 +111,15 @@ describe('dashboard middleware', () => {
|
||||
},
|
||||
}) as never)
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/dashboard/sign-in?redirect=%2Fdashboard%2Ffleet' })
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/en/light/sign-in?redirect=%2Fdashboard%2Ffleet' })
|
||||
})
|
||||
|
||||
it('redirects legacy dashboard sign-in requests to the localized homepage sign-in route', async () => {
|
||||
const { default: middleware } = await loadMiddleware('https://market.example.com')
|
||||
|
||||
const response = middleware(request('https://workspace.example.com/dashboard/sign-in?redirect=/dashboard/fleet&lang=fr&theme=dark') as never)
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/fr/dark/sign-in?redirect=%2Fdashboard%2Ffleet' })
|
||||
})
|
||||
|
||||
it('redirects signed-in users away from the sign-in page', async () => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { NextRequest } from 'next/server'
|
||||
const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000'
|
||||
const DASHBOARD_PUBLIC_URL = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? `${WEBSITE_URL.replace(/\/$/, '')}/dashboard`
|
||||
const DASHBOARD_BASE_PATH = '/dashboard'
|
||||
const DEFAULT_SIGN_IN_LOCALE = 'en'
|
||||
const DEFAULT_SIGN_IN_THEME = 'light'
|
||||
|
||||
function toDashboardAppPath(pathname: string): string {
|
||||
let normalized = pathname || '/'
|
||||
@@ -20,6 +22,33 @@ function toPublicDashboardPath(pathname: string): string {
|
||||
return appPath === '/' ? DASHBOARD_BASE_PATH : `${DASHBOARD_BASE_PATH}${appPath}`
|
||||
}
|
||||
|
||||
function resolveSignInPreference(value: string | undefined, allowed: string[], fallback: string) {
|
||||
return value && allowed.includes(value) ? value : fallback
|
||||
}
|
||||
|
||||
function resolveHomepageSignInUrl(req: NextRequest, redirectPath?: string): URL {
|
||||
const locale = resolveSignInPreference(
|
||||
req.nextUrl.searchParams.get('lang') ?? req.cookies.get('rentaldrivego-language')?.value,
|
||||
['en', 'fr', 'ar'],
|
||||
DEFAULT_SIGN_IN_LOCALE,
|
||||
)
|
||||
const theme = resolveSignInPreference(
|
||||
req.nextUrl.searchParams.get('theme') ?? req.cookies.get('rentaldrivego-theme')?.value ?? req.cookies.get('hpc-theme')?.value,
|
||||
['light', 'dark'],
|
||||
DEFAULT_SIGN_IN_THEME,
|
||||
)
|
||||
const signInUrl = new URL(`/${locale}/${theme}/sign-in`, new URL(WEBSITE_URL).origin)
|
||||
|
||||
if (redirectPath) signInUrl.searchParams.set('redirect', toPublicDashboardPath(redirectPath))
|
||||
|
||||
for (const key of ['portal', 'embedded']) {
|
||||
const value = req.nextUrl.searchParams.get(key)
|
||||
if (value) signInUrl.searchParams.set(key, value)
|
||||
}
|
||||
|
||||
return signInUrl
|
||||
}
|
||||
|
||||
function deduplicatePublicDashboardPath(pathname: string): string | null {
|
||||
if (!pathname.startsWith(`${DASHBOARD_BASE_PATH}${DASHBOARD_BASE_PATH}`)) return null
|
||||
|
||||
@@ -63,18 +92,19 @@ function localJwtMiddleware(req: NextRequest): NextResponse {
|
||||
const token = req.cookies.get('employee_session')?.value
|
||||
const pathname = toDashboardAppPath(req.nextUrl.pathname)
|
||||
|
||||
// Redirect signed-in users from sign-in to dashboard (through the website proxy)
|
||||
if (token && pathname === '/sign-in') {
|
||||
const dashboardUrl = resolveProxyUrl(req, DASHBOARD_BASE_PATH)
|
||||
return NextResponse.redirect(dashboardUrl)
|
||||
if (pathname === '/sign-in') {
|
||||
if (token) {
|
||||
const dashboardUrl = resolveProxyUrl(req, DASHBOARD_BASE_PATH)
|
||||
return NextResponse.redirect(dashboardUrl)
|
||||
}
|
||||
|
||||
return NextResponse.redirect(resolveHomepageSignInUrl(req, req.nextUrl.searchParams.get('redirect') ?? undefined))
|
||||
}
|
||||
|
||||
if (!isProtectedRoute(req)) return NextResponse.next()
|
||||
|
||||
if (!token) {
|
||||
const signInUrl = resolveProxyUrl(req, toPublicDashboardPath('/sign-in'))
|
||||
signInUrl.searchParams.set('redirect', toPublicDashboardPath(req.nextUrl.pathname))
|
||||
return NextResponse.redirect(signInUrl)
|
||||
return NextResponse.redirect(resolveHomepageSignInUrl(req, req.nextUrl.pathname))
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate an A4 French/Arabic vehicle rental contract as a print-ready PDF.
|
||||
|
||||
Usage:
|
||||
python generate_rental_contract.py
|
||||
|
||||
Edit the CONTRACT dictionary below, or import generate_contract() in your app.
|
||||
Requires: weasyprint (pip install weasyprint)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from html import escape
|
||||
|
||||
from weasyprint import HTML
|
||||
|
||||
OUTPUT_DIR = Path(__file__).resolve().parent
|
||||
|
||||
CONTRACT: dict[str, Any] = {
|
||||
"company_name": "CAR LOCATION",
|
||||
"company_address": "Adresse de la société, Midelt, Maroc",
|
||||
"delivery_place": "Midelt",
|
||||
"return_place": "Midelt",
|
||||
"issued_at": "Midelt le 06/06/2021 11:35",
|
||||
"driver": {
|
||||
"first_name": "Prénom",
|
||||
"last_name": "Nom",
|
||||
"birth_date": "01/01/1990",
|
||||
"nationality": "Marocain",
|
||||
"address": "Adresse du client",
|
||||
"phone": "+212 6 00 00 00 00",
|
||||
"cin": "AB123456",
|
||||
"passport": "",
|
||||
"license": "P123456",
|
||||
"license_issued": "09/03/2004",
|
||||
},
|
||||
"second_driver": None,
|
||||
"vehicle": {
|
||||
"make_model": "Dacia Dokker",
|
||||
"registration": "12345-A-6",
|
||||
"departure": "06/06/2021 11:35",
|
||||
"return": "08/06/2021 11:35",
|
||||
"duration": "2 jour(s)",
|
||||
"fuel_type": "Diesel",
|
||||
"extension": False,
|
||||
"extension_return": "",
|
||||
"spare_wheel": True,
|
||||
"radio_cd": False,
|
||||
"fuel_level": "1/4", # 0, 1/4, 1/2, 3/4 or 1
|
||||
},
|
||||
"notes": "",
|
||||
}
|
||||
|
||||
|
||||
def h(value: Any) -> str:
|
||||
return escape("" if value is None else str(value))
|
||||
|
||||
|
||||
def check(value: bool) -> str:
|
||||
return "☒" if value else "☐"
|
||||
|
||||
|
||||
def field(label_fr: str, label_ar: str, value: Any) -> str:
|
||||
return (
|
||||
'<div class="field">'
|
||||
f'<span class="label-fr">{h(label_fr)}</span>'
|
||||
f'<span class="value">{h(value)}</span>'
|
||||
f'<span class="label-ar" dir="rtl">{h(label_ar)}</span>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
def driver_block(driver: dict[str, Any] | None, title: str) -> str:
|
||||
if not driver:
|
||||
return f'''<section class="box driver-box">
|
||||
<div class="section-title">{h(title)}</div>
|
||||
<div class="empty-driver">Prénom: ***** AUCUN ***** <span dir="rtl">لا أحد</span><br>
|
||||
Nom: *****<br>Date de naissance: *****<br>N° de C.I.N.: *****<br>N° de Permis: *****<br>Délivrée le: *****</div>
|
||||
</section>'''
|
||||
return f'''<section class="box driver-box">
|
||||
<div class="section-title">{h(title)}</div>
|
||||
<div class="fields">
|
||||
{field("Prénom:", "الاسم", driver.get("first_name"))}
|
||||
{field("Nom:", "النسب", driver.get("last_name"))}
|
||||
{field("Date de naissance:", "تاريخ الازدياد", driver.get("birth_date"))}
|
||||
{field("Nationalité:", "الجنسية", driver.get("nationality"))}
|
||||
{field("Adresse:", "العنوان", driver.get("address"))}
|
||||
{field("N° téléphone:", "رقم الهاتف", driver.get("phone"))}
|
||||
{field("N° de C.I.N.:", "رقم البطاقة الوطنية", driver.get("cin"))}
|
||||
{field("N° de Passeport:", "رقم جواز السفر", driver.get("passport"))}
|
||||
{field("N° de Permis:", "رقم رخصة السياقة", driver.get("license"))}
|
||||
{field("Délivrée le:", "سلمت بتاريخ", driver.get("license_issued"))}
|
||||
</div>
|
||||
</section>'''
|
||||
|
||||
|
||||
def vehicle_diagram() -> str:
|
||||
return '''
|
||||
<svg class="vehicle-diagram" viewBox="0 0 560 250" xmlns="http://www.w3.org/2000/svg" aria-label="Schéma état du véhicule">
|
||||
<g fill="none" stroke="#222" stroke-width="3">
|
||||
<!-- top view -->
|
||||
<rect x="185" y="78" width="190" height="94" rx="35"/>
|
||||
<path d="M210 92 Q280 55 350 92 M210 158 Q280 195 350 158"/>
|
||||
<path d="M232 80 L230 170 M328 80 L330 170"/>
|
||||
<rect x="150" y="92" width="38" height="65" rx="8"/>
|
||||
<rect x="372" y="92" width="38" height="65" rx="8"/>
|
||||
<!-- front/back -->
|
||||
<path d="M55 52 Q105 16 155 52 L166 91 L45 91 Z"/>
|
||||
<circle cx="66" cy="90" r="13"/><circle cx="145" cy="90" r="13"/>
|
||||
<path d="M405 52 Q455 16 505 52 L516 91 L395 91 Z"/>
|
||||
<circle cx="416" cy="90" r="13"/><circle cx="495" cy="90" r="13"/>
|
||||
<!-- side views -->
|
||||
<path d="M38 210 L78 175 L160 169 L206 210 Z"/>
|
||||
<path d="M85 177 L107 145 L154 145 L180 173"/>
|
||||
<circle cx="78" cy="210" r="18"/><circle cx="170" cy="210" r="18"/>
|
||||
<path d="M354 210 L394 175 L476 169 L522 210 Z"/>
|
||||
<path d="M401 177 L423 145 L470 145 L496 173"/>
|
||||
<circle cx="394" cy="210" r="18"/><circle cx="486" cy="210" r="18"/>
|
||||
</g>
|
||||
<g font-family="DejaVu Sans" font-size="13" fill="#222">
|
||||
<text x="6" y="56">R: Rayures</text><text x="6" y="83">B: Bosses</text>
|
||||
<text x="6" y="110">E: Éclats</text><text x="6" y="137">C: Cassures</text>
|
||||
</g>
|
||||
</svg>'''
|
||||
|
||||
|
||||
def render_html(data: dict[str, Any]) -> str:
|
||||
v = data["vehicle"]
|
||||
levels = ["0", "1/4", "1/2", "3/4", "1"]
|
||||
fuel_marks = "".join(
|
||||
f'<div class="fuel-option"><span>{x}</span><b>{"X" if v.get("fuel_level") == x else ""}</b></div>'
|
||||
for x in levels
|
||||
)
|
||||
extension_yes = bool(v.get("extension"))
|
||||
return f'''<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Contrat de location</title>
|
||||
<style>
|
||||
@page {{ size: A4; margin: 8mm; }}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{ margin: 0; font-family: "DejaVu Sans", Arial, sans-serif; color: #111; font-size: 9.2pt; }}
|
||||
.page {{ width: 100%; }}
|
||||
.header {{ display:grid; grid-template-columns: 31% 69%; align-items:start; margin-bottom:4px; }}
|
||||
.logo {{ height:70px; display:flex; align-items:center; justify-content:center; font-size:19pt; font-weight:700; font-style:italic; letter-spacing:1px; }}
|
||||
.logo::before, .logo::after {{ content:""; display:block; width:45px; border-top:3px solid #111; margin:0 8px; }}
|
||||
h1 {{ margin: 0; text-align:center; font-family: Georgia, serif; font-size:28pt; text-decoration: underline; font-style:italic; }}
|
||||
.issued {{ text-align:center; font-weight:700; margin-top:10px; }}
|
||||
table {{ border-collapse: collapse; width:100%; }}
|
||||
.places td {{ border:1.3px solid #111; padding:6px; font-weight:700; text-align:center; }}
|
||||
.columns {{ display:grid; grid-template-columns: 52% 48%; gap:4px; margin-top:4px; }}
|
||||
.box {{ border:1.3px solid #111; margin-bottom:4px; break-inside:avoid; }}
|
||||
.section-title {{ text-align:center; font-weight:700; font-size:12pt; padding:4px; border-bottom:1.3px solid #111; background:#e5e5e5; font-family:Georgia, serif; }}
|
||||
.fields {{ padding:6px 7px 5px; }}
|
||||
.field {{ display:grid; grid-template-columns: 34% 43% 23%; min-height:24px; align-items:center; gap:3px; }}
|
||||
.label-fr, .label-ar {{ font-weight:600; }}
|
||||
.label-ar {{ text-align:right; font-family:"Noto Sans Arabic", "DejaVu Sans", sans-serif; }}
|
||||
.value {{ min-height:18px; border-bottom:1px dotted #777; padding:1px 4px; font-weight:700; }}
|
||||
.empty-driver {{ padding:10px; line-height:1.75; min-height:160px; }}
|
||||
.vehicle-details {{ padding:7px; }}
|
||||
.vehicle-details .row {{ display:grid; grid-template-columns:42% 58%; min-height:28px; align-items:center; }}
|
||||
.vehicle-details .row span:first-child {{ font-weight:700; }}
|
||||
.vehicle-details .row span:last-child {{ font-weight:700; border-bottom:1px dotted #777; }}
|
||||
.equipment {{ display:grid; grid-template-columns:1fr 1fr; gap:4px; }}
|
||||
.mini {{ border:1.3px solid #111; text-align:center; }}
|
||||
.mini-title {{ background:#e5e5e5; font-weight:700; padding:5px; border-bottom:1.3px solid #111; }}
|
||||
.mini-value {{ padding:7px; font-size:11pt; }}
|
||||
.fuel {{ border:1.3px solid #111; margin:4px 0; display:grid; grid-template-columns:30% 70%; }}
|
||||
.fuel-label {{ background:#e5e5e5; font-weight:700; display:flex; align-items:center; justify-content:center; border-right:1.3px solid #111; }}
|
||||
.fuel-options {{ display:grid; grid-template-columns:repeat(5,1fr); padding:4px 5px; }}
|
||||
.fuel-option {{ text-align:center; border-bottom:1px solid #111; min-height:34px; }}
|
||||
.fuel-option span {{ display:block; font-weight:700; }}
|
||||
.fuel-option b {{ display:block; font-size:13pt; line-height:14px; }}
|
||||
.diagram-box {{ border:1.3px solid #111; }}
|
||||
.vehicle-diagram {{ width:100%; height:200px; display:block; padding:2px; }}
|
||||
.notice {{ border:1.3px solid #111; background:#d8d8d8; text-align:center; font-weight:700; text-decoration:underline; font-size:11pt; padding:8px 20px; margin:4px 0; }}
|
||||
.signatures {{ display:grid; grid-template-columns:1fr 1fr; border:1.3px solid #111; min-height:124px; }}
|
||||
.signature {{ padding:8px; text-align:center; font-weight:700; text-decoration:underline; }}
|
||||
.signature + .signature {{ border-left:1.3px solid #111; }}
|
||||
.signature-space {{ height:80px; margin-top:6px; }}
|
||||
.footer-logo {{ margin:13px auto 5px; text-align:center; font-size:15pt; font-weight:700; font-style:italic; }}
|
||||
.footer {{ border-top:1px solid #777; padding-top:6px; font-weight:600; }}
|
||||
.notes {{ white-space:pre-wrap; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="header">
|
||||
<div class="logo">{h(data['company_name'])}</div>
|
||||
<div><h1>Contrat de location</h1><div class="issued">{h(data['issued_at'])}</div></div>
|
||||
</header>
|
||||
|
||||
<table class="places"><tr>
|
||||
<td>Lieu de livraison : {h(data['delivery_place'])}</td>
|
||||
<td>Lieu de reprise : {h(data['return_place'])}</td>
|
||||
</tr></table>
|
||||
|
||||
<main class="columns">
|
||||
<div>
|
||||
{driver_block(data.get('driver'), 'Conducteur I')}
|
||||
{driver_block(data.get('second_driver'), 'Conducteur II')}
|
||||
<div class="notice">Le client est seul responsable des délits, contraventions et infractions au code de la route.</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<section class="box">
|
||||
<div class="section-title">Véhicule</div>
|
||||
<div class="vehicle-details">
|
||||
<div class="row"><span>Marque :</span><span>{h(v.get('make_model'))}</span></div>
|
||||
<div class="row"><span>Matricule :</span><span>{h(v.get('registration'))}</span></div>
|
||||
<div class="row"><span>Date de départ :</span><span>{h(v.get('departure'))}</span></div>
|
||||
<div class="row"><span>Date de retour :</span><span>{h(v.get('return'))}</span></div>
|
||||
<div class="row"><span>Durée de location :</span><span>{h(v.get('duration'))}</span></div>
|
||||
<div class="row"><span>Carburant :</span><span>{h(v.get('fuel_type'))}</span></div>
|
||||
<div class="row"><span>Prolongation :</span><span>{check(extension_yes)} OUI {check(not extension_yes)} NON</span></div>
|
||||
<div class="row"><span>Retour en cas de prolongation :</span><span>{h(v.get('extension_return'))}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="equipment">
|
||||
<div class="mini"><div class="mini-title">Roue de secours</div><div class="mini-value">{check(bool(v.get('spare_wheel')))} OUI {check(not bool(v.get('spare_wheel')))} NON</div></div>
|
||||
<div class="mini"><div class="mini-title">Poste radio et CD</div><div class="mini-value">{check(bool(v.get('radio_cd')))} OUI {check(not bool(v.get('radio_cd')))} NON</div></div>
|
||||
</div>
|
||||
|
||||
<div class="fuel"><div class="fuel-label">Niveau carburant</div><div class="fuel-options">{fuel_marks}</div></div>
|
||||
|
||||
<section class="diagram-box">
|
||||
<div class="section-title">État général à la réception du client</div>
|
||||
{vehicle_diagram()}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section class="signatures">
|
||||
<div class="signature">Signature du client : lu et approuvé des conditions<div class="signature-space"></div></div>
|
||||
<div class="signature">Cachet et signature de la société<div class="signature-space"></div></div>
|
||||
</section>
|
||||
|
||||
<div class="footer-logo">{h(data['company_name'])}</div>
|
||||
<footer class="footer">Adresse : {h(data['company_address'])}<div class="notes">{h(data.get('notes'))}</div></footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
|
||||
def generate_contract(data: dict[str, Any], output_pdf: str | Path, output_html: str | Path | None = None) -> Path:
|
||||
"""Render contract data to a print-ready PDF and optionally save the HTML preview."""
|
||||
output_pdf = Path(output_pdf)
|
||||
output_pdf.parent.mkdir(parents=True, exist_ok=True)
|
||||
html = render_html(data)
|
||||
if output_html:
|
||||
output_html = Path(output_html)
|
||||
output_html.write_text(html, encoding="utf-8")
|
||||
HTML(string=html, base_url=str(OUTPUT_DIR)).write_pdf(str(output_pdf))
|
||||
return output_pdf
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pdf = generate_contract(
|
||||
CONTRACT,
|
||||
OUTPUT_DIR / "rental_contract.pdf",
|
||||
OUTPUT_DIR / "rental_contract_preview.html",
|
||||
)
|
||||
print(f"Generated: {pdf}")
|
||||
Binary file not shown.
@@ -0,0 +1,154 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Contrat de location</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 8mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: "DejaVu Sans", Arial, sans-serif; color: #111; font-size: 9.2pt; }
|
||||
.page { width: 100%; }
|
||||
.header { display:grid; grid-template-columns: 31% 69%; align-items:start; margin-bottom:4px; }
|
||||
.logo { height:70px; display:flex; align-items:center; justify-content:center; font-size:19pt; font-weight:700; font-style:italic; letter-spacing:1px; }
|
||||
.logo::before, .logo::after { content:""; display:block; width:45px; border-top:3px solid #111; margin:0 8px; }
|
||||
h1 { margin: 0; text-align:center; font-family: Georgia, serif; font-size:28pt; text-decoration: underline; font-style:italic; }
|
||||
.issued { text-align:center; font-weight:700; margin-top:10px; }
|
||||
table { border-collapse: collapse; width:100%; }
|
||||
.places td { border:1.3px solid #111; padding:6px; font-weight:700; text-align:center; }
|
||||
.columns { display:grid; grid-template-columns: 52% 48%; gap:4px; margin-top:4px; }
|
||||
.box { border:1.3px solid #111; margin-bottom:4px; break-inside:avoid; }
|
||||
.section-title { text-align:center; font-weight:700; font-size:12pt; padding:4px; border-bottom:1.3px solid #111; background:#e5e5e5; font-family:Georgia, serif; }
|
||||
.fields { padding:6px 7px 5px; }
|
||||
.field { display:grid; grid-template-columns: 34% 43% 23%; min-height:24px; align-items:center; gap:3px; }
|
||||
.label-fr, .label-ar { font-weight:600; }
|
||||
.label-ar { text-align:right; font-family:"Noto Sans Arabic", "DejaVu Sans", sans-serif; }
|
||||
.value { min-height:18px; border-bottom:1px dotted #777; padding:1px 4px; font-weight:700; }
|
||||
.empty-driver { padding:10px; line-height:1.75; min-height:160px; }
|
||||
.vehicle-details { padding:7px; }
|
||||
.vehicle-details .row { display:grid; grid-template-columns:42% 58%; min-height:28px; align-items:center; }
|
||||
.vehicle-details .row span:first-child { font-weight:700; }
|
||||
.vehicle-details .row span:last-child { font-weight:700; border-bottom:1px dotted #777; }
|
||||
.equipment { display:grid; grid-template-columns:1fr 1fr; gap:4px; }
|
||||
.mini { border:1.3px solid #111; text-align:center; }
|
||||
.mini-title { background:#e5e5e5; font-weight:700; padding:5px; border-bottom:1.3px solid #111; }
|
||||
.mini-value { padding:7px; font-size:11pt; }
|
||||
.fuel { border:1.3px solid #111; margin:4px 0; display:grid; grid-template-columns:30% 70%; }
|
||||
.fuel-label { background:#e5e5e5; font-weight:700; display:flex; align-items:center; justify-content:center; border-right:1.3px solid #111; }
|
||||
.fuel-options { display:grid; grid-template-columns:repeat(5,1fr); padding:4px 5px; }
|
||||
.fuel-option { text-align:center; border-bottom:1px solid #111; min-height:34px; }
|
||||
.fuel-option span { display:block; font-weight:700; }
|
||||
.fuel-option b { display:block; font-size:13pt; line-height:14px; }
|
||||
.diagram-box { border:1.3px solid #111; }
|
||||
.vehicle-diagram { width:100%; height:200px; display:block; padding:2px; }
|
||||
.notice { border:1.3px solid #111; background:#d8d8d8; text-align:center; font-weight:700; text-decoration:underline; font-size:11pt; padding:8px 20px; margin:4px 0; }
|
||||
.signatures { display:grid; grid-template-columns:1fr 1fr; border:1.3px solid #111; min-height:124px; }
|
||||
.signature { padding:8px; text-align:center; font-weight:700; text-decoration:underline; }
|
||||
.signature + .signature { border-left:1.3px solid #111; }
|
||||
.signature-space { height:80px; margin-top:6px; }
|
||||
.footer-logo { margin:13px auto 5px; text-align:center; font-size:15pt; font-weight:700; font-style:italic; }
|
||||
.footer { border-top:1px solid #777; padding-top:6px; font-weight:600; }
|
||||
.notes { white-space:pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="header">
|
||||
<div class="logo">CAR LOCATION</div>
|
||||
<div><h1>Contrat de location</h1><div class="issued">Midelt le 06/06/2021 11:35</div></div>
|
||||
</header>
|
||||
|
||||
<table class="places"><tr>
|
||||
<td>Lieu de livraison : Midelt</td>
|
||||
<td>Lieu de reprise : Midelt</td>
|
||||
</tr></table>
|
||||
|
||||
<main class="columns">
|
||||
<div>
|
||||
<section class="box driver-box">
|
||||
<div class="section-title">Conducteur I</div>
|
||||
<div class="fields">
|
||||
<div class="field"><span class="label-fr">Prénom:</span><span class="value">Prénom</span><span class="label-ar" dir="rtl">الاسم</span></div>
|
||||
<div class="field"><span class="label-fr">Nom:</span><span class="value">Nom</span><span class="label-ar" dir="rtl">النسب</span></div>
|
||||
<div class="field"><span class="label-fr">Date de naissance:</span><span class="value">01/01/1990</span><span class="label-ar" dir="rtl">تاريخ الازدياد</span></div>
|
||||
<div class="field"><span class="label-fr">Nationalité:</span><span class="value">Marocain</span><span class="label-ar" dir="rtl">الجنسية</span></div>
|
||||
<div class="field"><span class="label-fr">Adresse:</span><span class="value">Adresse du client</span><span class="label-ar" dir="rtl">العنوان</span></div>
|
||||
<div class="field"><span class="label-fr">N° téléphone:</span><span class="value">+212 6 00 00 00 00</span><span class="label-ar" dir="rtl">رقم الهاتف</span></div>
|
||||
<div class="field"><span class="label-fr">N° de C.I.N.:</span><span class="value">AB123456</span><span class="label-ar" dir="rtl">رقم البطاقة الوطنية</span></div>
|
||||
<div class="field"><span class="label-fr">N° de Passeport:</span><span class="value"></span><span class="label-ar" dir="rtl">رقم جواز السفر</span></div>
|
||||
<div class="field"><span class="label-fr">N° de Permis:</span><span class="value">P123456</span><span class="label-ar" dir="rtl">رقم رخصة السياقة</span></div>
|
||||
<div class="field"><span class="label-fr">Délivrée le:</span><span class="value">09/03/2004</span><span class="label-ar" dir="rtl">سلمت بتاريخ</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="box driver-box">
|
||||
<div class="section-title">Conducteur II</div>
|
||||
<div class="empty-driver">Prénom: ***** AUCUN ***** <span dir="rtl">لا أحد</span><br>
|
||||
Nom: *****<br>Date de naissance: *****<br>N° de C.I.N.: *****<br>N° de Permis: *****<br>Délivrée le: *****</div>
|
||||
</section>
|
||||
<div class="notice">Le client est seul responsable des délits, contraventions et infractions au code de la route.</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<section class="box">
|
||||
<div class="section-title">Véhicule</div>
|
||||
<div class="vehicle-details">
|
||||
<div class="row"><span>Marque :</span><span>Dacia Dokker</span></div>
|
||||
<div class="row"><span>Matricule :</span><span>12345-A-6</span></div>
|
||||
<div class="row"><span>Date de départ :</span><span>06/06/2021 11:35</span></div>
|
||||
<div class="row"><span>Date de retour :</span><span>08/06/2021 11:35</span></div>
|
||||
<div class="row"><span>Durée de location :</span><span>2 jour(s)</span></div>
|
||||
<div class="row"><span>Carburant :</span><span>Diesel</span></div>
|
||||
<div class="row"><span>Prolongation :</span><span>☐ OUI ☒ NON</span></div>
|
||||
<div class="row"><span>Retour en cas de prolongation :</span><span></span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="equipment">
|
||||
<div class="mini"><div class="mini-title">Roue de secours</div><div class="mini-value">☒ OUI ☐ NON</div></div>
|
||||
<div class="mini"><div class="mini-title">Poste radio et CD</div><div class="mini-value">☐ OUI ☒ NON</div></div>
|
||||
</div>
|
||||
|
||||
<div class="fuel"><div class="fuel-label">Niveau carburant</div><div class="fuel-options"><div class="fuel-option"><span>0</span><b></b></div><div class="fuel-option"><span>1/4</span><b>X</b></div><div class="fuel-option"><span>1/2</span><b></b></div><div class="fuel-option"><span>3/4</span><b></b></div><div class="fuel-option"><span>1</span><b></b></div></div></div>
|
||||
|
||||
<section class="diagram-box">
|
||||
<div class="section-title">État général à la réception du client</div>
|
||||
|
||||
<svg class="vehicle-diagram" viewBox="0 0 560 250" xmlns="http://www.w3.org/2000/svg" aria-label="Schéma état du véhicule">
|
||||
<g fill="none" stroke="#222" stroke-width="3">
|
||||
<!-- top view -->
|
||||
<rect x="185" y="78" width="190" height="94" rx="35"/>
|
||||
<path d="M210 92 Q280 55 350 92 M210 158 Q280 195 350 158"/>
|
||||
<path d="M232 80 L230 170 M328 80 L330 170"/>
|
||||
<rect x="150" y="92" width="38" height="65" rx="8"/>
|
||||
<rect x="372" y="92" width="38" height="65" rx="8"/>
|
||||
<!-- front/back -->
|
||||
<path d="M55 52 Q105 16 155 52 L166 91 L45 91 Z"/>
|
||||
<circle cx="66" cy="90" r="13"/><circle cx="145" cy="90" r="13"/>
|
||||
<path d="M405 52 Q455 16 505 52 L516 91 L395 91 Z"/>
|
||||
<circle cx="416" cy="90" r="13"/><circle cx="495" cy="90" r="13"/>
|
||||
<!-- side views -->
|
||||
<path d="M38 210 L78 175 L160 169 L206 210 Z"/>
|
||||
<path d="M85 177 L107 145 L154 145 L180 173"/>
|
||||
<circle cx="78" cy="210" r="18"/><circle cx="170" cy="210" r="18"/>
|
||||
<path d="M354 210 L394 175 L476 169 L522 210 Z"/>
|
||||
<path d="M401 177 L423 145 L470 145 L496 173"/>
|
||||
<circle cx="394" cy="210" r="18"/><circle cx="486" cy="210" r="18"/>
|
||||
</g>
|
||||
<g font-family="DejaVu Sans" font-size="13" fill="#222">
|
||||
<text x="6" y="56">R: Rayures</text><text x="6" y="83">B: Bosses</text>
|
||||
<text x="6" y="110">E: Éclats</text><text x="6" y="137">C: Cassures</text>
|
||||
</g>
|
||||
</svg>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section class="signatures">
|
||||
<div class="signature">Signature du client : lu et approuvé des conditions<div class="signature-space"></div></div>
|
||||
<div class="signature">Cachet et signature de la société<div class="signature-space"></div></div>
|
||||
</section>
|
||||
|
||||
<div class="footer-logo">CAR LOCATION</div>
|
||||
<footer class="footer">Adresse : Adresse de la société, Midelt, Maroc<div class="notes"></div></footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -206,7 +206,7 @@ POST /api/v1/auth/company/signup
|
||||
|
||||
### Sign in
|
||||
|
||||
Navigate to `/sign-in` (redirects to `/dashboard/sign-in`). Enter the owner email and the password set during sign-up.
|
||||
Navigate to `/en/light/sign-in`. Enter the owner email and the password set during sign-up.
|
||||
|
||||
### Onboarding
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@ Base path:
|
||||
|
||||
### Public/auth routes
|
||||
|
||||
- `/dashboard/sign-in`
|
||||
- `/en/light/sign-in`
|
||||
- `/dashboard/sign-in` (legacy redirect)
|
||||
- `/dashboard/sign-up`
|
||||
- `/dashboard/forgot-password`
|
||||
- `/dashboard/reset-password`
|
||||
|
||||
Reference in New Issue
Block a user