fix the contract view and edit
This commit is contained in:
@@ -12,9 +12,15 @@ const signupSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(128),
|
||||
companyName: z.string().min(2).max(120),
|
||||
companyPhone: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
legalForm: z.string().min(1).max(80),
|
||||
registrationNumber: z.string().min(1).max(120),
|
||||
streetAddress: z.string().min(1).max(200),
|
||||
city: z.string().min(1).max(120),
|
||||
country: z.string().min(1).max(120),
|
||||
zipCode: z.string().min(1).max(40),
|
||||
companyPhone: z.string().min(1).max(80),
|
||||
fax: z.string().max(80).optional(),
|
||||
yearsActive: z.string().min(1).max(80),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']),
|
||||
@@ -129,7 +135,17 @@ router.post('/signup', async (req, res, next) => {
|
||||
name: body.companyName,
|
||||
slug,
|
||||
email: body.email,
|
||||
phone: body.companyPhone || null,
|
||||
phone: body.companyPhone,
|
||||
address: {
|
||||
streetAddress: body.streetAddress,
|
||||
city: body.city,
|
||||
country: body.country,
|
||||
zipCode: body.zipCode,
|
||||
legalForm: body.legalForm,
|
||||
managerName: `${body.firstName} ${body.lastName}`.trim(),
|
||||
fax: body.fax || null,
|
||||
yearsActive: body.yearsActive,
|
||||
},
|
||||
status: 'TRIALING',
|
||||
},
|
||||
})
|
||||
@@ -140,13 +156,22 @@ router.post('/signup', async (req, res, next) => {
|
||||
displayName: body.companyName,
|
||||
subdomain: slug,
|
||||
publicEmail: body.email,
|
||||
publicPhone: body.companyPhone || null,
|
||||
publicCountry: body.country || null,
|
||||
publicCity: body.city || null,
|
||||
publicPhone: body.companyPhone,
|
||||
publicAddress: body.streetAddress,
|
||||
publicCity: body.city,
|
||||
publicCountry: body.country,
|
||||
defaultCurrency: body.currency,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.contractSettings.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
legalName: body.companyName,
|
||||
registrationNumber: body.registrationNumber,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.subscription.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
@@ -168,7 +193,7 @@ router.post('/signup', async (req, res, next) => {
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
email: body.email,
|
||||
phone: body.companyPhone || null,
|
||||
phone: body.companyPhone,
|
||||
passwordHash,
|
||||
role: 'OWNER',
|
||||
isActive: true,
|
||||
|
||||
@@ -99,6 +99,13 @@ const chargeSchema = z.object({
|
||||
failureUrl: z.string().url(),
|
||||
})
|
||||
|
||||
const manualPaymentSchema = z.object({
|
||||
amount: z.number().int().positive(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
|
||||
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'),
|
||||
paymentMethod: z.enum(['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD', 'PAYPAL']),
|
||||
})
|
||||
|
||||
router.get('/company', async (req, res, next) => {
|
||||
try {
|
||||
const payments = await prisma.rentalPayment.findMany({
|
||||
@@ -226,6 +233,51 @@ router.post('/reservations/:id/capture-paypal', async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:id/manual', async (req, res, next) => {
|
||||
try {
|
||||
const { amount, currency, type, paymentMethod } = manualPaymentSchema.parse(req.body)
|
||||
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
})
|
||||
|
||||
const remainingBalance = Math.max(reservation.totalAmount - reservation.paidAmount, 0)
|
||||
if (remainingBalance <= 0) {
|
||||
return res.status(409).json({ error: 'already_paid', message: 'Reservation is already fully paid', statusCode: 409 })
|
||||
}
|
||||
if (amount > remainingBalance) {
|
||||
return res.status(400).json({ error: 'amount_exceeds_balance', message: 'Payment amount exceeds remaining balance', statusCode: 400 })
|
||||
}
|
||||
|
||||
const payment = await prisma.rentalPayment.create({
|
||||
data: {
|
||||
companyId: req.companyId,
|
||||
reservationId: reservation.id,
|
||||
amount,
|
||||
currency,
|
||||
status: 'SUCCEEDED',
|
||||
type,
|
||||
paymentProvider: paymentMethod === 'PAYPAL' ? 'PAYPAL' : 'AMANPAY',
|
||||
paymentMethod,
|
||||
paidAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
const newPaidAmount = reservation.paidAmount + amount
|
||||
const paymentStatus = newPaidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL'
|
||||
|
||||
await prisma.reservation.update({
|
||||
where: { id: reservation.id },
|
||||
data: {
|
||||
paidAmount: newPaidAmount,
|
||||
paymentStatus,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: payment })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:reservationId/payments/:paymentId/refund', async (req, res, next) => {
|
||||
try {
|
||||
const { amount, reason } = z.object({
|
||||
@@ -240,6 +292,9 @@ router.post('/reservations/:reservationId/payments/:paymentId/refund', async (re
|
||||
if (payment.status !== 'SUCCEEDED') {
|
||||
return res.status(400).json({ error: 'not_capturable', message: 'Only succeeded payments can be refunded', statusCode: 400 })
|
||||
}
|
||||
if (!payment.amanpayTransactionId && !payment.paypalCaptureId) {
|
||||
return res.status(400).json({ error: 'manual_refund_unsupported', message: 'Manual payments must be refunded outside the online gateway flow', statusCode: 400 })
|
||||
}
|
||||
|
||||
const refundAmount = amount ?? payment.amount
|
||||
|
||||
|
||||
@@ -62,6 +62,99 @@ const createSchema = z.object({
|
||||
additionalDrivers: z.array(additionalDriverSchema).default([]),
|
||||
})
|
||||
|
||||
const updateSchema = z.object({
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
pickupLocation: z.string().optional().nullable(),
|
||||
returnLocation: z.string().optional().nullable(),
|
||||
depositAmount: z.number().int().min(0).optional(),
|
||||
notes: z.string().optional().nullable(),
|
||||
paymentMode: z.string().max(50).optional().nullable(),
|
||||
damageChargeAmount: z.number().int().min(0).optional(),
|
||||
damageChargeNote: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
function parseReservationExtras(extras: unknown) {
|
||||
if (!extras || typeof extras !== 'object' || Array.isArray(extras)) return {} as Record<string, unknown>
|
||||
return { ...(extras as Record<string, unknown>) }
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: string | null | undefined) {
|
||||
const next = typeof value === 'string' ? value.trim() : value
|
||||
return next ? next : null
|
||||
}
|
||||
|
||||
function calculateUpdatedInsuranceCharge(
|
||||
chargeType: 'PER_DAY' | 'PER_RENTAL' | 'PERCENTAGE_OF_RENTAL',
|
||||
chargeValue: number,
|
||||
totalDays: number,
|
||||
baseRentalAmount: number,
|
||||
) {
|
||||
switch (chargeType) {
|
||||
case 'PER_DAY':
|
||||
return chargeValue * totalDays
|
||||
case 'PER_RENTAL':
|
||||
return chargeValue
|
||||
case 'PERCENTAGE_OF_RENTAL':
|
||||
return Math.round(baseRentalAmount * chargeValue / 100)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function calculateUpdatedAdditionalDriverCharge(
|
||||
chargeType: 'PER_DAY' | 'FLAT' | 'FREE',
|
||||
chargeValue: number,
|
||||
totalDays: number,
|
||||
) {
|
||||
switch (chargeType) {
|
||||
case 'PER_DAY':
|
||||
return chargeValue * totalDays
|
||||
case 'FLAT':
|
||||
return chargeValue
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function buildReservationWorkflow(reservation: {
|
||||
status: string
|
||||
contractNumber: string | null
|
||||
invoiceNumber: string | null
|
||||
extras: unknown
|
||||
}) {
|
||||
const extras = parseReservationExtras(reservation.extras)
|
||||
const closedAt = typeof extras.reservationClosedAt === 'string' ? extras.reservationClosedAt : null
|
||||
const closedBy = typeof extras.reservationClosedBy === 'string' ? extras.reservationClosedBy : null
|
||||
const contractGenerated = Boolean(reservation.contractNumber || reservation.invoiceNumber)
|
||||
const closed = Boolean(closedAt)
|
||||
|
||||
return {
|
||||
contractGenerated,
|
||||
closed,
|
||||
closedAt,
|
||||
closedBy,
|
||||
coreEditable: !closed && !contractGenerated && ['DRAFT', 'CONFIRMED'].includes(reservation.status),
|
||||
returnEditable: !closed && reservation.status === 'COMPLETED',
|
||||
checkInInspectionEditable: !closed && ['CONFIRMED', 'ACTIVE'].includes(reservation.status),
|
||||
checkOutInspectionEditable: !closed && reservation.status === 'COMPLETED',
|
||||
}
|
||||
}
|
||||
|
||||
function serializeReservationForDashboard<T extends {
|
||||
extras: unknown
|
||||
status: string
|
||||
contractNumber: string | null
|
||||
invoiceNumber: string | null
|
||||
}>(reservation: T) {
|
||||
const extras = parseReservationExtras(reservation.extras)
|
||||
return {
|
||||
...reservation,
|
||||
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
|
||||
workflow: buildReservationWorkflow(reservation),
|
||||
}
|
||||
}
|
||||
|
||||
function formatDocumentNumber(prefix: string, sequence: number) {
|
||||
return `${prefix}-${String(sequence).padStart(6, '0')}`
|
||||
}
|
||||
@@ -228,7 +321,13 @@ router.get('/', async (req, res, next) => {
|
||||
prisma.reservation.count({ where }),
|
||||
])
|
||||
|
||||
res.json({ data: reservations, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) })
|
||||
res.json({
|
||||
data: reservations.map((reservation) => serializeReservationForDashboard(reservation)),
|
||||
total,
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
totalPages: Math.ceil(total / parseInt(pageSize)),
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
@@ -317,7 +416,152 @@ router.get('/:id', async (req, res, next) => {
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true },
|
||||
})
|
||||
res.json({ data: reservation })
|
||||
res.json({ data: serializeReservationForDashboard(reservation) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const body = updateSchema.parse(req.body)
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
include: {
|
||||
insurances: true,
|
||||
additionalDrivers: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (reservation.status === 'CANCELLED' || reservation.status === 'NO_SHOW') {
|
||||
return res.status(400).json({ error: 'invalid_status', message: 'This reservation can no longer be edited', statusCode: 400 })
|
||||
}
|
||||
|
||||
const workflow = buildReservationWorkflow(reservation)
|
||||
const isReturnEdit = workflow.returnEditable
|
||||
const requestedFields = Object.keys(body).filter((key) => body[key as keyof typeof body] !== undefined)
|
||||
const bookingFields = ['startDate', 'endDate', 'pickupLocation', 'returnLocation', 'depositAmount', 'notes', 'paymentMode']
|
||||
const returnFields = ['returnLocation', 'damageChargeAmount', 'damageChargeNote']
|
||||
|
||||
if (!workflow.coreEditable && !isReturnEdit) {
|
||||
return res.status(400).json({ error: 'reservation_locked', message: 'This reservation is locked for editing', statusCode: 400 })
|
||||
}
|
||||
|
||||
if (workflow.coreEditable) {
|
||||
const invalidFields = requestedFields.filter((field) => !bookingFields.includes(field))
|
||||
if (invalidFields.length > 0) {
|
||||
return res.status(400).json({ error: 'invalid_fields', message: 'Only booking details can be edited at this stage', statusCode: 400 })
|
||||
}
|
||||
|
||||
const nextStartDate = body.startDate ? new Date(body.startDate) : reservation.startDate
|
||||
const nextEndDate = body.endDate ? new Date(body.endDate) : reservation.endDate
|
||||
const totalDays = Math.ceil((nextEndDate.getTime() - nextStartDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (nextEndDate <= nextStartDate || totalDays <= 0) {
|
||||
return res.status(400).json({ error: 'invalid_dates', message: 'End date must be after start date', statusCode: 400 })
|
||||
}
|
||||
|
||||
if (body.startDate || body.endDate) {
|
||||
const conflict = await prisma.reservation.findFirst({
|
||||
where: {
|
||||
id: { not: reservation.id },
|
||||
vehicleId: reservation.vehicleId,
|
||||
status: { in: ['DRAFT', 'CONFIRMED', 'ACTIVE'] },
|
||||
startDate: { lt: nextEndDate },
|
||||
endDate: { gt: nextStartDate },
|
||||
},
|
||||
})
|
||||
|
||||
if (conflict) {
|
||||
return res.status(409).json({ error: 'vehicle_unavailable', message: 'Vehicle is not available for the selected dates', statusCode: 409 })
|
||||
}
|
||||
}
|
||||
|
||||
const baseAmount = reservation.dailyRate * totalDays
|
||||
const { applied, total: pricingRulesTotal } = await applyPricingRules(
|
||||
req.companyId,
|
||||
reservation.customerId,
|
||||
reservation.additionalDrivers.map((driver) => ({
|
||||
dateOfBirth: driver.dateOfBirth,
|
||||
licenseIssuedAt: driver.licenseIssuedAt,
|
||||
})),
|
||||
reservation.dailyRate,
|
||||
totalDays,
|
||||
)
|
||||
|
||||
const insuranceUpdates = reservation.insurances.map((insurance) => ({
|
||||
id: insurance.id,
|
||||
totalCharge: calculateUpdatedInsuranceCharge(insurance.chargeType, insurance.chargeValue, totalDays, baseAmount),
|
||||
}))
|
||||
const additionalDriverUpdates = reservation.additionalDrivers.map((driver) => ({
|
||||
id: driver.id,
|
||||
totalCharge: calculateUpdatedAdditionalDriverCharge(driver.chargeType, driver.chargeValue, totalDays),
|
||||
}))
|
||||
|
||||
const insuranceTotal = insuranceUpdates.reduce((sum, insurance) => sum + insurance.totalCharge, 0)
|
||||
const additionalDriverTotal = additionalDriverUpdates.reduce((sum, driver) => sum + driver.totalCharge, 0)
|
||||
const depositAmount = body.depositAmount ?? reservation.depositAmount
|
||||
const totalAmount = baseAmount - reservation.discountAmount + pricingRulesTotal + insuranceTotal + additionalDriverTotal + depositAmount
|
||||
|
||||
const extras = parseReservationExtras(reservation.extras)
|
||||
if (body.paymentMode !== undefined) {
|
||||
const nextPaymentMode = normalizeOptionalString(body.paymentMode)
|
||||
if (nextPaymentMode) extras.paymentMode = nextPaymentMode
|
||||
else delete extras.paymentMode
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.reservation.update({
|
||||
where: { id: reservation.id },
|
||||
data: {
|
||||
startDate: nextStartDate,
|
||||
endDate: nextEndDate,
|
||||
totalDays,
|
||||
pickupLocation: body.pickupLocation !== undefined ? normalizeOptionalString(body.pickupLocation) : reservation.pickupLocation,
|
||||
returnLocation: body.returnLocation !== undefined ? normalizeOptionalString(body.returnLocation) : reservation.returnLocation,
|
||||
depositAmount,
|
||||
notes: body.notes !== undefined ? normalizeOptionalString(body.notes) : reservation.notes,
|
||||
pricingRulesApplied: applied,
|
||||
pricingRulesTotal,
|
||||
insuranceTotal,
|
||||
additionalDriverTotal,
|
||||
totalAmount,
|
||||
extras: (Object.keys(extras).length > 0 ? extras : {}) as any,
|
||||
},
|
||||
}),
|
||||
...insuranceUpdates.map((insurance) =>
|
||||
prisma.reservationInsurance.update({
|
||||
where: { id: insurance.id },
|
||||
data: { totalCharge: insurance.totalCharge },
|
||||
})
|
||||
),
|
||||
...additionalDriverUpdates.map((driver) =>
|
||||
prisma.additionalDriver.update({
|
||||
where: { id: driver.id },
|
||||
data: { totalCharge: driver.totalCharge },
|
||||
})
|
||||
),
|
||||
])
|
||||
} else {
|
||||
const invalidFields = requestedFields.filter((field) => !returnFields.includes(field))
|
||||
if (invalidFields.length > 0) {
|
||||
return res.status(400).json({ error: 'invalid_fields', message: 'Only return details can be edited after vehicle return', statusCode: 400 })
|
||||
}
|
||||
|
||||
await prisma.reservation.update({
|
||||
where: { id: reservation.id },
|
||||
data: {
|
||||
returnLocation: body.returnLocation !== undefined ? normalizeOptionalString(body.returnLocation) : reservation.returnLocation,
|
||||
damageChargeAmount: body.damageChargeAmount !== undefined ? body.damageChargeAmount : reservation.damageChargeAmount,
|
||||
damageChargeNote: body.damageChargeNote !== undefined ? normalizeOptionalString(body.damageChargeNote) : reservation.damageChargeNote,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const updated = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true },
|
||||
})
|
||||
|
||||
res.json({ data: serializeReservationForDashboard(updated) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
@@ -385,6 +629,7 @@ router.get('/:id/contract', async (req, res, next) => {
|
||||
res.json({
|
||||
data: {
|
||||
reservationId: reservation.id,
|
||||
contractLocale: reservation.company.brand?.defaultLocale ?? 'en',
|
||||
contractNumber: reservation.contractNumber,
|
||||
invoiceNumber: reservation.invoiceNumber,
|
||||
generatedAt: new Date().toISOString(),
|
||||
@@ -571,6 +816,35 @@ router.post('/:id/checkout', async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/close', async (req, res, next) => {
|
||||
try {
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
})
|
||||
const workflow = buildReservationWorkflow(reservation)
|
||||
|
||||
if (reservation.status !== 'COMPLETED') {
|
||||
return res.status(400).json({ error: 'invalid_status', message: 'Only completed reservations can be closed', statusCode: 400 })
|
||||
}
|
||||
|
||||
if (workflow.closed) {
|
||||
return res.status(400).json({ error: 'already_closed', message: 'This reservation is already closed', statusCode: 400 })
|
||||
}
|
||||
|
||||
const extras = parseReservationExtras(reservation.extras)
|
||||
extras.reservationClosedAt = new Date().toISOString()
|
||||
extras.reservationClosedBy = `${req.employee.firstName} ${req.employee.lastName}`
|
||||
|
||||
const updated = await prisma.reservation.update({
|
||||
where: { id: reservation.id },
|
||||
data: { extras: extras as any },
|
||||
include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true },
|
||||
})
|
||||
|
||||
res.json({ data: serializeReservationForDashboard(updated) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/cancel', async (req, res, next) => {
|
||||
try {
|
||||
const { reason } = z.object({ reason: z.string().optional() }).parse(req.body)
|
||||
@@ -626,6 +900,19 @@ router.put('/:id/inspections/:type', async (req, res, next) => {
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
include: { vehicle: true, customer: true },
|
||||
})
|
||||
const workflow = buildReservationWorkflow(reservation)
|
||||
|
||||
if (workflow.closed) {
|
||||
return res.status(400).json({ error: 'reservation_closed', message: 'This reservation has already been closed', statusCode: 400 })
|
||||
}
|
||||
|
||||
if (type === 'CHECKIN' && !workflow.checkInInspectionEditable) {
|
||||
return res.status(400).json({ error: 'invalid_status', message: 'Check-in inspection is not editable at this stage', statusCode: 400 })
|
||||
}
|
||||
|
||||
if (type === 'CHECKOUT' && !workflow.checkOutInspectionEditable) {
|
||||
return res.status(400).json({ error: 'invalid_status', message: 'Check-out inspection is only editable after the vehicle is returned', statusCode: 400 })
|
||||
}
|
||||
|
||||
const inspection = await prisma.damageInspection.upsert({
|
||||
where: { reservationId_type: { reservationId: reservation.id, type } },
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
@@ -1,166 +1,654 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
type Currency = 'MAD' | 'USD' | 'EUR'
|
||||
type ReservationRow = {
|
||||
id: string
|
||||
invoiceNumber: string | null
|
||||
contractNumber: string | null
|
||||
status: string
|
||||
paymentStatus: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
totalAmount: number
|
||||
depositAmount: number
|
||||
paidAmount: number
|
||||
customer: { firstName: string; lastName: string; email: string }
|
||||
vehicle: { make: string; model: string; licensePlate: string }
|
||||
}
|
||||
|
||||
type PaymentRow = {
|
||||
id: string
|
||||
reservationId: string
|
||||
amount: number
|
||||
currency: Currency
|
||||
currency: 'MAD' | 'USD' | 'EUR'
|
||||
status: string
|
||||
type: 'CHARGE' | 'DEPOSIT'
|
||||
paymentProvider: 'AMANPAY' | 'PAYPAL'
|
||||
paymentMethod: string | null
|
||||
paidAt: string | null
|
||||
createdAt: string
|
||||
reservation: {
|
||||
id: string
|
||||
customer: { firstName: string; lastName: string }
|
||||
vehicle: { make: string; model: string; licensePlate: string }
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
type BillingRow = ReservationRow & {
|
||||
balanceDue: number
|
||||
paymentCount: number
|
||||
latestPayment: PaymentRow | null
|
||||
}
|
||||
|
||||
type ManualPaymentMethod = 'CASH' | 'CHECK' | 'BANK_TRANSFER' | 'CARD' | 'PAYPAL'
|
||||
|
||||
const PAYMENT_STATUS_BADGE: Record<string, string> = {
|
||||
UNPAID: 'bg-rose-100 text-rose-700',
|
||||
PAID: 'bg-emerald-100 text-emerald-700',
|
||||
PARTIAL: 'bg-amber-100 text-amber-700',
|
||||
PENDING: 'bg-sky-100 text-sky-700',
|
||||
REFUNDED: 'bg-slate-100 text-slate-700',
|
||||
FAILED: 'bg-rose-100 text-rose-700',
|
||||
OVERDUE: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const PAYMENT_EVENT_BADGE: Record<string, string> = {
|
||||
PENDING: 'bg-amber-100 text-amber-700',
|
||||
SUCCEEDED: 'bg-green-100 text-green-700',
|
||||
FAILED: 'bg-red-100 text-red-700',
|
||||
REFUNDED: 'bg-slate-100 text-slate-600',
|
||||
PARTIALLY_REFUNDED: 'bg-blue-100 text-blue-700',
|
||||
SUCCEEDED: 'bg-emerald-100 text-emerald-700',
|
||||
FAILED: 'bg-rose-100 text-rose-700',
|
||||
REFUNDED: 'bg-slate-100 text-slate-700',
|
||||
PARTIALLY_REFUNDED: 'bg-sky-100 text-sky-700',
|
||||
}
|
||||
|
||||
export default function RentCarBillingPage() {
|
||||
export default function BillingPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
|
||||
const [reservations, setReservations] = useState<ReservationRow[]>([])
|
||||
const [payments, setPayments] = useState<PaymentRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [paymentModalRow, setPaymentModalRow] = useState<BillingRow | null>(null)
|
||||
const [paymentMethod, setPaymentMethod] = useState<ManualPaymentMethod>('CASH')
|
||||
const [paymentType, setPaymentType] = useState<'CHARGE' | 'DEPOSIT'>('CHARGE')
|
||||
const [paymentCurrency, setPaymentCurrency] = useState<'MAD' | 'USD' | 'EUR'>('MAD')
|
||||
const [paymentAmount, setPaymentAmount] = useState('')
|
||||
const [submittingPayment, setSubmittingPayment] = useState(false)
|
||||
const [paymentError, setPaymentError] = useState<string | null>(null)
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Rent Car Billing',
|
||||
subtitle: 'Track rental payment transactions across all reservations.',
|
||||
bookCar: 'Book car',
|
||||
loading: 'Loading payments…',
|
||||
empty: 'No rental payments yet.',
|
||||
failed: 'Failed to load rental payments.',
|
||||
reservation: 'Reservation',
|
||||
heading: 'Customer Billing',
|
||||
subtitle: 'Manage customer invoices, payment status, collected amounts, and remaining balances.',
|
||||
search: 'Search by customer, invoice, contract, vehicle, or plate…',
|
||||
totalBilled: 'Total billed',
|
||||
totalCollected: 'Collected',
|
||||
outstanding: 'Outstanding',
|
||||
openInvoices: 'Open invoices',
|
||||
customer: 'Customer',
|
||||
vehicle: 'Vehicle',
|
||||
type: 'Type',
|
||||
provider: 'Provider',
|
||||
invoice: 'Invoice',
|
||||
rentalPeriod: 'Rental period',
|
||||
total: 'Total',
|
||||
paid: 'Paid',
|
||||
balance: 'Balance',
|
||||
status: 'Status',
|
||||
created: 'Created',
|
||||
paidAt: 'Paid at',
|
||||
amount: 'Amount',
|
||||
charge: 'Charge',
|
||||
payment: 'Payment',
|
||||
paymentMethod: 'Payment method',
|
||||
actions: 'Actions',
|
||||
contract: 'Contract',
|
||||
deposit: 'Deposit',
|
||||
acceptPayment: 'Accept payment',
|
||||
paymentModalTitle: 'Accept customer payment',
|
||||
paymentModalSubtitle: 'Record a payment received for this reservation. No online provider setup is required.',
|
||||
paymentType: 'Payment type',
|
||||
paymentCurrency: 'Payment currency',
|
||||
paymentAmount: 'Payment amount',
|
||||
cancel: 'Cancel',
|
||||
savingPayment: 'Saving payment…',
|
||||
savePayment: 'Save payment',
|
||||
chargeHelp: 'Charge records a payment against the reservation balance.',
|
||||
depositHelp: 'Deposit records money received for the refundable security deposit.',
|
||||
invalidAmount: 'Enter a valid payment amount within the remaining balance.',
|
||||
paymentActionDisabled: 'No balance due',
|
||||
paymentsRecorded: (count: number) => `${count} payment(s)`,
|
||||
none: '—',
|
||||
loading: 'Loading customer billing…',
|
||||
noRows: 'No customer bills found.',
|
||||
failed: 'Failed to load customer billing.',
|
||||
openContract: 'Open contract',
|
||||
openBooking: 'Open booking',
|
||||
charge: 'Charge',
|
||||
depositType: 'Deposit',
|
||||
unknownInvoice: 'Draft invoice',
|
||||
paymentStatusLabels: {
|
||||
UNPAID: 'Unpaid',
|
||||
PAID: 'Paid',
|
||||
PARTIAL: 'Partial',
|
||||
PENDING: 'Pending',
|
||||
REFUNDED: 'Refunded',
|
||||
FAILED: 'Failed',
|
||||
OVERDUE: 'Overdue',
|
||||
} as Record<string, string>,
|
||||
paymentEventLabels: {
|
||||
PENDING: 'Pending',
|
||||
SUCCEEDED: 'Succeeded',
|
||||
FAILED: 'Failed',
|
||||
REFUNDED: 'Refunded',
|
||||
PARTIALLY_REFUNDED: 'Partially refunded',
|
||||
} as Record<string, string>,
|
||||
paymentProviderLabels: {
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<string, string>,
|
||||
paymentMethodLabels: {
|
||||
CASH: 'Cash',
|
||||
CHECK: 'Check',
|
||||
BANK_TRANSFER: 'Bank transfer',
|
||||
CARD: 'Card',
|
||||
PAYPAL: 'PayPal',
|
||||
STRIPE: 'Card',
|
||||
CREDIT_CARD: 'Card',
|
||||
DEBIT_CARD: 'Card',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
fr: {
|
||||
title: 'Facturation location',
|
||||
subtitle: 'Suivez les paiements de location sur toutes les réservations.',
|
||||
bookCar: 'Réserver une voiture',
|
||||
loading: 'Chargement des paiements…',
|
||||
empty: 'Aucun paiement de location pour le moment.',
|
||||
failed: 'Échec du chargement des paiements de location.',
|
||||
reservation: 'Réservation',
|
||||
heading: 'Facturation clients',
|
||||
subtitle: 'Gérez les factures clients, le statut des paiements, les montants encaissés et les soldes restants.',
|
||||
search: 'Rechercher par client, facture, contrat, véhicule ou plaque…',
|
||||
totalBilled: 'Total facturé',
|
||||
totalCollected: 'Encaissé',
|
||||
outstanding: 'Solde restant',
|
||||
openInvoices: 'Factures ouvertes',
|
||||
customer: 'Client',
|
||||
vehicle: 'Véhicule',
|
||||
type: 'Type',
|
||||
provider: 'Prestataire',
|
||||
invoice: 'Facture',
|
||||
rentalPeriod: 'Période',
|
||||
total: 'Total',
|
||||
paid: 'Payé',
|
||||
balance: 'Solde',
|
||||
status: 'Statut',
|
||||
created: 'Créé le',
|
||||
paidAt: 'Payé le',
|
||||
amount: 'Montant',
|
||||
charge: 'Paiement',
|
||||
payment: 'Paiement',
|
||||
paymentMethod: 'Mode de paiement',
|
||||
actions: 'Actions',
|
||||
contract: 'Contrat',
|
||||
deposit: 'Dépôt',
|
||||
acceptPayment: 'Encaisser',
|
||||
paymentModalTitle: 'Encaisser un paiement client',
|
||||
paymentModalSubtitle: 'Enregistrez un paiement reçu pour cette réservation. Aucune configuration de fournisseur en ligne n’est requise.',
|
||||
paymentType: 'Type de paiement',
|
||||
paymentCurrency: 'Devise du paiement',
|
||||
paymentAmount: 'Montant du paiement',
|
||||
cancel: 'Annuler',
|
||||
savingPayment: 'Enregistrement…',
|
||||
savePayment: 'Enregistrer le paiement',
|
||||
chargeHelp: 'Paiement enregistre un règlement sur le solde de la réservation.',
|
||||
depositHelp: 'Dépôt enregistre l’encaissement du dépôt de garantie remboursable.',
|
||||
invalidAmount: 'Saisissez un montant valide dans la limite du solde restant.',
|
||||
paymentActionDisabled: 'Aucun solde dû',
|
||||
paymentsRecorded: (count: number) => `${count} paiement(s)`,
|
||||
none: '—',
|
||||
loading: 'Chargement de la facturation client…',
|
||||
noRows: 'Aucune facture client trouvée.',
|
||||
failed: 'Échec du chargement de la facturation client.',
|
||||
openContract: 'Ouvrir le contrat',
|
||||
openBooking: 'Ouvrir la réservation',
|
||||
charge: 'Paiement',
|
||||
depositType: 'Dépôt',
|
||||
unknownInvoice: 'Facture brouillon',
|
||||
paymentStatusLabels: {
|
||||
UNPAID: 'Non payé',
|
||||
PAID: 'Payé',
|
||||
PARTIAL: 'Partiel',
|
||||
PENDING: 'En attente',
|
||||
REFUNDED: 'Remboursé',
|
||||
FAILED: 'Échoué',
|
||||
OVERDUE: 'En retard',
|
||||
} as Record<string, string>,
|
||||
paymentEventLabels: {
|
||||
PENDING: 'En attente',
|
||||
SUCCEEDED: 'Réussi',
|
||||
FAILED: 'Échoué',
|
||||
REFUNDED: 'Remboursé',
|
||||
PARTIALLY_REFUNDED: 'Partiellement remboursé',
|
||||
} as Record<string, string>,
|
||||
paymentProviderLabels: {
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<string, string>,
|
||||
paymentMethodLabels: {
|
||||
CASH: 'Espèces',
|
||||
CHECK: 'Chèque',
|
||||
BANK_TRANSFER: 'Virement bancaire',
|
||||
CARD: 'Carte',
|
||||
PAYPAL: 'PayPal',
|
||||
STRIPE: 'Carte',
|
||||
CREDIT_CARD: 'Carte',
|
||||
DEBIT_CARD: 'Carte',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
ar: {
|
||||
title: 'فوترة تأجير السيارات',
|
||||
subtitle: 'تتبع معاملات دفع الإيجار عبر جميع الحجوزات.',
|
||||
bookCar: 'حجز سيارة',
|
||||
loading: 'جارٍ تحميل المدفوعات…',
|
||||
empty: 'لا توجد مدفوعات تأجير حتى الآن.',
|
||||
failed: 'فشل تحميل مدفوعات التأجير.',
|
||||
reservation: 'الحجز',
|
||||
heading: 'فوترة العملاء',
|
||||
subtitle: 'إدارة فواتير العملاء وحالة الدفع والمبالغ المحصلة والأرصدة المتبقية.',
|
||||
search: 'ابحث بالعميل أو الفاتورة أو العقد أو السيارة أو اللوحة…',
|
||||
totalBilled: 'إجمالي الفواتير',
|
||||
totalCollected: 'المحصّل',
|
||||
outstanding: 'المتبقي',
|
||||
openInvoices: 'الفواتير المفتوحة',
|
||||
customer: 'العميل',
|
||||
vehicle: 'المركبة',
|
||||
type: 'النوع',
|
||||
provider: 'المزوّد',
|
||||
invoice: 'الفاتورة',
|
||||
rentalPeriod: 'فترة الإيجار',
|
||||
total: 'الإجمالي',
|
||||
paid: 'المدفوع',
|
||||
balance: 'الرصيد',
|
||||
status: 'الحالة',
|
||||
created: 'تاريخ الإنشاء',
|
||||
paidAt: 'تاريخ الدفع',
|
||||
amount: 'المبلغ',
|
||||
payment: 'الدفعة',
|
||||
paymentMethod: 'طريقة الدفع',
|
||||
actions: 'الإجراءات',
|
||||
contract: 'العقد',
|
||||
deposit: 'العربون',
|
||||
acceptPayment: 'تحصيل دفعة',
|
||||
paymentModalTitle: 'تحصيل دفعة من العميل',
|
||||
paymentModalSubtitle: 'سجّل دفعة مستلمة لهذا الحجز. لا يتطلب ذلك إعداد مزود دفع عبر الإنترنت.',
|
||||
paymentType: 'نوع الدفع',
|
||||
paymentCurrency: 'عملة الدفع',
|
||||
paymentAmount: 'مبلغ الدفعة',
|
||||
cancel: 'إلغاء',
|
||||
savingPayment: 'جارٍ الحفظ…',
|
||||
savePayment: 'حفظ الدفعة',
|
||||
chargeHelp: 'الدفعة تسجل مبلغاً مستلماً على رصيد الحجز.',
|
||||
depositHelp: 'العربون يسجل مبلغ التأمين القابل للاسترداد المستلم.',
|
||||
invalidAmount: 'أدخل مبلغاً صحيحاً ضمن الرصيد المتبقي.',
|
||||
paymentActionDisabled: 'لا يوجد رصيد مستحق',
|
||||
paymentsRecorded: (count: number) => `${count} دفعة`,
|
||||
none: '—',
|
||||
loading: 'جارٍ تحميل فوترة العملاء…',
|
||||
noRows: 'لم يتم العثور على فواتير عملاء.',
|
||||
failed: 'فشل تحميل فوترة العملاء.',
|
||||
openContract: 'فتح العقد',
|
||||
openBooking: 'فتح الحجز',
|
||||
charge: 'دفعة',
|
||||
deposit: 'عربون',
|
||||
depositType: 'عربون',
|
||||
unknownInvoice: 'فاتورة مسودة',
|
||||
paymentStatusLabels: {
|
||||
UNPAID: 'غير مدفوع',
|
||||
PAID: 'مدفوع',
|
||||
PARTIAL: 'جزئي',
|
||||
PENDING: 'قيد الانتظار',
|
||||
REFUNDED: 'مسترد',
|
||||
FAILED: 'فشل',
|
||||
OVERDUE: 'متأخر',
|
||||
} as Record<string, string>,
|
||||
paymentEventLabels: {
|
||||
PENDING: 'قيد الانتظار',
|
||||
SUCCEEDED: 'ناجح',
|
||||
FAILED: 'فشل',
|
||||
REFUNDED: 'مسترد',
|
||||
PARTIALLY_REFUNDED: 'مسترد جزئياً',
|
||||
} as Record<string, string>,
|
||||
paymentProviderLabels: {
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<string, string>,
|
||||
paymentMethodLabels: {
|
||||
CASH: 'نقداً',
|
||||
CHECK: 'شيك',
|
||||
BANK_TRANSFER: 'تحويل بنكي',
|
||||
CARD: 'بطاقة',
|
||||
PAYPAL: 'PayPal',
|
||||
STRIPE: 'بطاقة',
|
||||
CREDIT_CARD: 'بطاقة',
|
||||
DEBIT_CARD: 'بطاقة',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
}[language]
|
||||
|
||||
async function loadBillingData() {
|
||||
const [reservationRows, paymentRows] = await Promise.all([
|
||||
apiFetch<ReservationRow[]>('/reservations?pageSize=100'),
|
||||
apiFetch<PaymentRow[]>('/payments/company'),
|
||||
])
|
||||
setReservations(reservationRows ?? [])
|
||||
setPayments(paymentRows ?? [])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<PaymentRow[]>('/payments/company')
|
||||
.then((rows) => setPayments(rows ?? []))
|
||||
loadBillingData()
|
||||
.catch((err) => setError(err.message ?? copy.failed))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
}, [copy.failed])
|
||||
|
||||
const billingRows = useMemo<BillingRow[]>(() => {
|
||||
const paymentsByReservation = new Map<string, PaymentRow[]>()
|
||||
|
||||
for (const payment of payments) {
|
||||
const existing = paymentsByReservation.get(payment.reservationId) ?? []
|
||||
existing.push(payment)
|
||||
paymentsByReservation.set(payment.reservationId, existing)
|
||||
}
|
||||
|
||||
return reservations.map((reservation) => {
|
||||
const reservationPayments = (paymentsByReservation.get(reservation.id) ?? []).sort((a, b) =>
|
||||
new Date(b.paidAt ?? b.createdAt).getTime() - new Date(a.paidAt ?? a.createdAt).getTime(),
|
||||
)
|
||||
|
||||
return {
|
||||
...reservation,
|
||||
balanceDue: Math.max(reservation.totalAmount - reservation.paidAmount, 0),
|
||||
paymentCount: reservationPayments.length,
|
||||
latestPayment: reservationPayments[0] ?? null,
|
||||
}
|
||||
})
|
||||
}, [payments, reservations])
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return billingRows
|
||||
return billingRows.filter((row) =>
|
||||
`${row.customer.firstName} ${row.customer.lastName}`.toLowerCase().includes(q) ||
|
||||
row.customer.email.toLowerCase().includes(q) ||
|
||||
`${row.vehicle.make} ${row.vehicle.model}`.toLowerCase().includes(q) ||
|
||||
row.vehicle.licensePlate.toLowerCase().includes(q) ||
|
||||
(row.invoiceNumber ?? '').toLowerCase().includes(q) ||
|
||||
(row.contractNumber ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
}, [billingRows, search])
|
||||
|
||||
const totals = useMemo(() => {
|
||||
return filteredRows.reduce(
|
||||
(acc, row) => {
|
||||
acc.totalBilled += row.totalAmount
|
||||
acc.totalCollected += row.paidAmount
|
||||
acc.outstanding += row.balanceDue
|
||||
if (row.balanceDue > 0) acc.openInvoices += 1
|
||||
return acc
|
||||
},
|
||||
{ totalBilled: 0, totalCollected: 0, outstanding: 0, openInvoices: 0 },
|
||||
)
|
||||
}, [filteredRows])
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString(localeCode, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function translateStatus(status: string, labels: Record<string, string>) {
|
||||
return labels[status] ?? status.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
function translatePaymentMethod(method: string | null) {
|
||||
if (!method) return copy.none
|
||||
return copy.paymentMethodLabels[method] ?? method.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
function canAcceptPayment(row: BillingRow) {
|
||||
return row.balanceDue > 0
|
||||
}
|
||||
|
||||
function openPaymentModal(row: BillingRow) {
|
||||
setPaymentModalRow(row)
|
||||
setPaymentMethod('CASH')
|
||||
setPaymentType('CHARGE')
|
||||
setPaymentCurrency('MAD')
|
||||
setPaymentAmount(String((row.balanceDue / 100).toFixed(2)))
|
||||
setPaymentError(null)
|
||||
}
|
||||
|
||||
async function handleAcceptPayment() {
|
||||
if (!paymentModalRow) return
|
||||
|
||||
const amount = Math.round(Number(paymentAmount) * 100)
|
||||
if (!Number.isFinite(amount) || amount <= 0 || amount > paymentModalRow.balanceDue) {
|
||||
setPaymentError(copy.invalidAmount)
|
||||
return
|
||||
}
|
||||
|
||||
setSubmittingPayment(true)
|
||||
setPaymentError(null)
|
||||
|
||||
try {
|
||||
await apiFetch<PaymentRow>(`/payments/reservations/${paymentModalRow.id}/manual`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
amount,
|
||||
type: paymentType,
|
||||
currency: paymentCurrency,
|
||||
paymentMethod,
|
||||
}),
|
||||
})
|
||||
await loadBillingData()
|
||||
setPaymentModalRow(null)
|
||||
} catch (err: any) {
|
||||
setPaymentError(err.message ?? copy.failed)
|
||||
} finally {
|
||||
setSubmittingPayment(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{copy.heading}</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
|
||||
</div>
|
||||
<Link href="/dashboard/reservations/new" className="btn-primary whitespace-nowrap">
|
||||
{copy.bookCar}
|
||||
</Link>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={copy.search}
|
||||
className="input-field w-full lg:max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <div className="card p-4 text-sm text-red-700">{error}</div> : null}
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard label={copy.totalBilled} value={formatCurrency(totals.totalBilled, 'MAD')} />
|
||||
<MetricCard label={copy.totalCollected} value={formatCurrency(totals.totalCollected, 'MAD')} />
|
||||
<MetricCard label={copy.outstanding} value={formatCurrency(totals.outstanding, 'MAD')} />
|
||||
<MetricCard label={copy.openInvoices} value={totals.openInvoices.toLocaleString(localeCode)} />
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-200">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.reservation}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.customer}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.vehicle}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.type}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.provider}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.status}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.created}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.paidAt}</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.amount}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
<tr><td colSpan={9} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
|
||||
) : payments.length === 0 ? (
|
||||
<tr><td colSpan={9} className="px-6 py-10 text-center text-sm text-slate-400">{copy.empty}</td></tr>
|
||||
) : payments.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="px-6 py-4 text-sm font-medium text-slate-900">#{row.reservation.id.slice(0, 8)}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.reservation.customer.firstName} {row.reservation.customer.lastName}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.reservation.vehicle.make} {row.reservation.vehicle.model} · {row.reservation.vehicle.licensePlate}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.type === 'DEPOSIT' ? copy.deposit : copy.charge}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.paymentProvider}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[row.status] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||
{row.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">{new Date(row.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">{row.paidAt ? new Date(row.paidAt).toLocaleDateString() : '—'}</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.amount, row.currency)}</td>
|
||||
{error ? (
|
||||
<div className="p-6 text-sm text-red-600">{error}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.customer}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.invoice}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.rentalPeriod}</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.paid}</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.balance}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.status}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.payment}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.paymentMethod}</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.actions}</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td>
|
||||
</tr>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noRows}</td>
|
||||
</tr>
|
||||
) : filteredRows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<p className="font-semibold text-slate-900">{row.customer.firstName} {row.customer.lastName}</p>
|
||||
<p className="text-xs text-slate-500">{row.customer.email}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">
|
||||
<p>{row.vehicle.make} {row.vehicle.model}</p>
|
||||
<p className="text-xs text-slate-500">{row.vehicle.licensePlate}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">
|
||||
<p className="font-medium text-slate-900">{row.invoiceNumber ?? copy.unknownInvoice}</p>
|
||||
<p className="text-xs text-slate-500">{copy.contract}: {row.contractNumber ?? copy.none}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-600">
|
||||
<p>{formatDate(row.startDate)}</p>
|
||||
<p>{formatDate(row.endDate)}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">
|
||||
{formatCurrency(row.totalAmount, 'MAD')}
|
||||
{row.depositAmount > 0 ? (
|
||||
<p className="text-xs font-normal text-slate-500">{copy.deposit}: {formatCurrency(row.depositAmount, 'MAD')}</p>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-semibold text-emerald-700">{formatCurrency(row.paidAmount, 'MAD')}</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-semibold text-amber-700">{formatCurrency(row.balanceDue, 'MAD')}</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${PAYMENT_STATUS_BADGE[row.paymentStatus] ?? 'bg-slate-100 text-slate-700'}`}>
|
||||
{translateStatus(row.paymentStatus, copy.paymentStatusLabels)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">
|
||||
{row.latestPayment ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${PAYMENT_EVENT_BADGE[row.latestPayment.status] ?? 'bg-slate-100 text-slate-700'}`}>
|
||||
{translateStatus(row.latestPayment.status, copy.paymentEventLabels)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{copy.paymentProviderLabels[row.latestPayment.paymentProvider] ?? row.latestPayment.paymentProvider}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{row.latestPayment.type === 'DEPOSIT' ? copy.depositType : copy.charge} · {formatCurrency(row.latestPayment.amount, row.latestPayment.currency)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">{formatDate(row.latestPayment.paidAt ?? row.latestPayment.createdAt)} · {copy.paymentsRecorded(row.paymentCount)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-400">{copy.none}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">
|
||||
{row.latestPayment ? (
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-slate-900">{translatePaymentMethod(row.latestPayment.paymentMethod)}</p>
|
||||
<p className="text-xs text-slate-500">{copy.paymentProviderLabels[row.latestPayment.paymentProvider] ?? row.latestPayment.paymentProvider}</p>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-400">{copy.none}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-sm">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{canAcceptPayment(row) ? (
|
||||
<button type="button" onClick={() => openPaymentModal(row)} className="font-semibold text-emerald-700 hover:underline">
|
||||
{copy.acceptPayment}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">{copy.paymentActionDisabled}</span>
|
||||
)}
|
||||
<Link href={`/dashboard/contracts/${row.id}`} className="font-semibold text-blue-700 hover:underline">
|
||||
{copy.openContract}
|
||||
</Link>
|
||||
<Link href={`/dashboard/reservations/${row.id}`} className="text-slate-600 hover:text-slate-900 hover:underline">
|
||||
{copy.openBooking}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{paymentModalRow ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm" onClick={(event) => {
|
||||
if (event.target === event.currentTarget && !submittingPayment) setPaymentModalRow(null)
|
||||
}}>
|
||||
<div className="w-full max-w-lg rounded-3xl bg-white p-6 shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">{copy.paymentModalTitle}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.paymentModalSubtitle}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setPaymentModalRow(null)} disabled={submittingPayment} className="text-slate-400 transition hover:text-slate-700">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
<p className="font-semibold text-slate-900">{paymentModalRow.customer.firstName} {paymentModalRow.customer.lastName}</p>
|
||||
<p className="mt-1">{paymentModalRow.vehicle.make} {paymentModalRow.vehicle.model} · {paymentModalRow.vehicle.licensePlate}</p>
|
||||
<p className="mt-1">{copy.invoice}: {paymentModalRow.invoiceNumber ?? copy.unknownInvoice}</p>
|
||||
<p className="mt-2 font-semibold text-slate-900">{copy.balance}: {formatCurrency(paymentModalRow.balanceDue, paymentCurrency)}</p>
|
||||
</div>
|
||||
|
||||
{paymentError ? (
|
||||
<div className="mt-5 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
|
||||
{paymentError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentMethod}</label>
|
||||
<select className="input-field" value={paymentMethod} onChange={(event) => setPaymentMethod(event.target.value as ManualPaymentMethod)} disabled={submittingPayment}>
|
||||
<option value="CASH">{copy.paymentMethodLabels.CASH}</option>
|
||||
<option value="CHECK">{copy.paymentMethodLabels.CHECK}</option>
|
||||
<option value="BANK_TRANSFER">{copy.paymentMethodLabels.BANK_TRANSFER}</option>
|
||||
<option value="CARD">{copy.paymentMethodLabels.CARD}</option>
|
||||
<option value="PAYPAL">{copy.paymentMethodLabels.PAYPAL}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentType}</label>
|
||||
<select className="input-field" value={paymentType} onChange={(event) => setPaymentType(event.target.value as 'CHARGE' | 'DEPOSIT')} disabled={submittingPayment}>
|
||||
{paymentModalRow.depositAmount > 0 ? <option value="DEPOSIT">{copy.depositType}</option> : null}
|
||||
<option value="CHARGE">{copy.charge}</option>
|
||||
</select>
|
||||
<p className="mt-2 text-xs text-slate-500">{paymentType === 'DEPOSIT' ? copy.depositHelp : copy.chargeHelp}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentCurrency}</label>
|
||||
<select className="input-field" value={paymentCurrency} onChange={(event) => setPaymentCurrency(event.target.value as 'MAD' | 'USD' | 'EUR')} disabled={submittingPayment}>
|
||||
<option value="MAD">MAD</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentAmount}</label>
|
||||
<input type="number" min="0" step="0.01" className="input-field" value={paymentAmount} onChange={(event) => setPaymentAmount(event.target.value)} disabled={submittingPayment} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button type="button" onClick={() => setPaymentModalRow(null)} disabled={submittingPayment} className="flex-1 rounded-full border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
|
||||
{copy.cancel}
|
||||
</button>
|
||||
<button type="button" onClick={handleAcceptPayment} disabled={submittingPayment} className="btn-primary flex-1 justify-center disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{submittingPayment ? copy.savingPayment : copy.savePayment}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<p className="text-sm font-medium text-slate-500">{label}</p>
|
||||
<p className="mt-2 text-2xl font-bold text-slate-900">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ import { useParams } from 'next/navigation'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import VehicleConditionSheet from '@/components/reservations/VehicleConditionSheet'
|
||||
|
||||
type DamagePoint = {
|
||||
id?: string
|
||||
viewType?: 'TOP' | 'FRONT' | 'REAR' | 'LEFT' | 'RIGHT'
|
||||
x: number
|
||||
y: number
|
||||
damageType: 'SCRATCH' | 'DENT' | 'CRACK' | 'CHIP' | 'MISSING' | 'STAIN' | 'OTHER'
|
||||
@@ -19,6 +21,7 @@ type DamagePoint = {
|
||||
|
||||
type ContractPayload = {
|
||||
reservationId: string
|
||||
contractLocale: string
|
||||
contractNumber: string | null
|
||||
invoiceNumber: string | null
|
||||
generatedAt: string
|
||||
@@ -145,38 +148,6 @@ type ContractPayload = {
|
||||
}
|
||||
}
|
||||
|
||||
const severityColors: Record<DamagePoint['severity'], string> = {
|
||||
MINOR: '#f59e0b',
|
||||
MODERATE: '#f97316',
|
||||
MAJOR: '#dc2626',
|
||||
}
|
||||
|
||||
function VehicleDamageDiagram({ points }: { points: DamagePoint[] }) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 180" className="w-full max-w-[120px] rounded-2xl border border-slate-200 bg-white p-2">
|
||||
<rect x="30" y="12" width="40" height="156" rx="18" fill="#e2e8f0" stroke="#94a3b8" />
|
||||
<rect x="36" y="22" width="28" height="24" rx="8" fill="#cbd5e1" />
|
||||
<rect x="36" y="54" width="28" height="70" rx="8" fill="#f8fafc" stroke="#cbd5e1" />
|
||||
<rect x="36" y="132" width="28" height="20" rx="8" fill="#cbd5e1" />
|
||||
<rect x="18" y="38" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
<rect x="70" y="38" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
<rect x="18" y="114" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
<rect x="70" y="114" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
{points.map((point, index) => (
|
||||
<circle
|
||||
key={`${point.x}-${point.y}-${index}`}
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r="4.2"
|
||||
fill={severityColors[point.severity]}
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function formatAddress(address: string | Record<string, unknown> | null, city: string | null, country: string | null) {
|
||||
const lines: string[] = []
|
||||
if (typeof address === 'string' && address.trim()) lines.push(address.trim())
|
||||
@@ -208,6 +179,161 @@ function splitTerms(text: string | null | undefined) {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
type ConditionLanguage = 'en' | 'fr' | 'ar'
|
||||
|
||||
type ConditionItem = {
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
const FIXED_CONDITIONS: Record<ConditionLanguage, ConditionItem[]> = {
|
||||
en: [
|
||||
{
|
||||
title: 'Article 1: Use of the vehicle',
|
||||
body: 'The renter agrees that only the persons named in the contract may drive the vehicle.\n\nThe following are prohibited:\n- using the vehicle for unlawful purposes;\n- transporting prohibited goods;\n- towing;\n- transporting passengers for payment;\n- driving small vehicles on unpaved roads.',
|
||||
},
|
||||
{
|
||||
title: 'Article 2: Condition of the vehicle',
|
||||
body: 'The vehicle is delivered in proper mechanical, electrical, tire, and cleanliness condition and must be returned in the same condition.\n\nThe authorized mileage is limited to 400 km per day. Any excess mileage is billed at 1.20 DH per additional kilometer.',
|
||||
},
|
||||
{
|
||||
title: 'Article 3: Maintenance and repair',
|
||||
body: 'Any maintenance or repair work on the vehicle must be authorized by the rental agency, whether by fax or email.\n\nAny breakdown caused by the customer’s negligence is at the customer’s expense.',
|
||||
},
|
||||
{
|
||||
title: 'Article 4: Insurance',
|
||||
body: '1. Full coverage insurance\nIf the renter is responsible for an accident, the renter must pay:\n- the deductible set by the insurance company;\n- immobilization costs during the repair period.\n\nThe renter must notify the agency immediately in the event of an accident.\n\n2. Standard insurance\nThe renter is solely responsible in the event of an accident and must pay:\n- all repair costs;\n- compensation for the period during which the vehicle is out of service.',
|
||||
},
|
||||
{
|
||||
title: 'Article 5: Rental extension',
|
||||
body: 'Any extension must be authorized by the rental agency. Otherwise, the renter is responsible for all costs caused by negligence.\n\nIn case of extension, the renter must notify the agency one day in advance.\n\nThe vehicle must be returned at the scheduled time. Any delay exceeding two hours will result in an additional day being charged.\n\nThe company reserves the right to terminate the contract without justification or compensation if the renter does not comply with the contract conditions.\n\nNo refund will be made in case of early return.\n\nIn the event of an unauthorized extension, the customer must pay 100 dirhams per hour until the vehicle is returned.',
|
||||
},
|
||||
{
|
||||
title: 'Article 6: Vehicle documents',
|
||||
body: 'In case of loss of the vehicle documents, all related costs remain the responsibility of the renter, including:\n- vehicle immobilization;\n- renewal of the documents.',
|
||||
},
|
||||
{
|
||||
title: 'Article 7: Liability',
|
||||
body: 'The renter is responsible for:\n- fines;\n- violations;\n- official reports issued against the renter by the authorities.\n\nThe driver remains financially responsible for damage caused to the vehicle while driving under the influence of:\n- alcohol;\n- drugs;\n- medication prohibited while driving.\n\nThe renter must not abandon the vehicle for any reason.',
|
||||
},
|
||||
{
|
||||
title: 'Article 8: Cancellation of the renter’s rights',
|
||||
body: 'All rights granted to the renter under this contract are cancelled if any clause of the contract is breached, without any compensation for the remaining rental period.',
|
||||
},
|
||||
{
|
||||
title: 'Article 9: Disputes',
|
||||
body: 'Any dispute between the rental company and the renter falls under the exclusive jurisdiction of the competent courts at the registered office of the company.',
|
||||
},
|
||||
],
|
||||
fr: [
|
||||
{
|
||||
title: 'Article 1 : Utilisation de la voiture',
|
||||
body: 'Le locataire s’engage à ne laisser conduire la voiture qu’aux personnes désignées dans le contrat.\n\nIl est interdit :\n- d’utiliser le véhicule à des fins illicites ;\n- de transporter des marchandises interdites ;\n- d’effectuer du remorquage ;\n- de transporter des personnes contre rémunération ;\n- d’utiliser les petites voitures sur des pistes non goudronnées.',
|
||||
},
|
||||
{
|
||||
title: 'Article 2 : État de la voiture',
|
||||
body: 'Le véhicule est livré en parfait état de propreté mécanique, électrique et pneumatique. Il doit être rendu dans le même état.\n\nLe kilométrage autorisé est fixé à 400 km par jour. En cas de dépassement, le locataire devra payer 1,20 DH par kilomètre supplémentaire.',
|
||||
},
|
||||
{
|
||||
title: 'Article 3 : Entretien et réparation',
|
||||
body: 'Toute opération d’entretien ou de réparation du véhicule doit être autorisée par l’agence de location, soit par fax, soit par email.\n\nToute panne causée par la négligence du client sera à sa charge.',
|
||||
},
|
||||
{
|
||||
title: 'Article 4 : Assurance',
|
||||
body: '1. Assurance tous risques\nSi le locataire est responsable d’un accident, il devra payer :\n- la franchise fixée par la compagnie d’assurance ;\n- les frais d’immobilisation du véhicule pendant la période de réparation.\n\nLe locataire doit informer immédiatement l’agence en cas d’accident.\n\n2. Assurance normale\nLe locataire est l’unique responsable en cas d’accident et doit payer :\n- tous les frais de réparation ;\n- les jours d’immobilisation du véhicule.',
|
||||
},
|
||||
{
|
||||
title: 'Article 5 : Prolongation de location',
|
||||
body: 'Toute prolongation doit être autorisée par l’agence de location. À défaut, le locataire sera responsable de tous les frais causés par sa négligence.\n\nEn cas de prolongation, le locataire doit prévenir l’agence un jour à l’avance.\n\nLe véhicule doit être rendu à l’heure prévue. Tout retard dépassant deux heures entraînera la facturation d’une journée supplémentaire.\n\nLa société se réserve le droit de mettre fin au contrat sans justification ni compensation si le locataire ne respecte pas les conditions du contrat.\n\nEn cas de retour anticipé, aucun remboursement ne sera effectué.\n\nEn cas de prolongation non autorisée, le client devra payer 100 dirhams par heure jusqu’à la restitution du véhicule.',
|
||||
},
|
||||
{
|
||||
title: 'Article 6 : Papiers de la voiture',
|
||||
body: 'En cas de perte des papiers du véhicule, tous les frais restent à la charge du locataire, y compris :\n- immobilisation du véhicule ;\n- renouvellement des papiers.',
|
||||
},
|
||||
{
|
||||
title: 'Article 7 : Responsabilité',
|
||||
body: 'Le locataire est responsable :\n- des amendes ;\n- des contraventions ;\n- des procès-verbaux établis contre lui par les autorités.\n\nLe conducteur restera financièrement responsable des dommages causés au véhicule en cas de conduite sous l’emprise :\n- de l’alcool ;\n- de drogues ;\n- de médicaments interdits pendant la conduite.\n\nLe locataire ne doit pas abandonner le véhicule, quelle qu’en soit la raison.',
|
||||
},
|
||||
{
|
||||
title: 'Article 8 : Annulation du droit du locataire',
|
||||
body: 'Tous les droits accordés au locataire par ce contrat seront annulés en cas de non-respect d’une clause du contrat, sans aucune indemnisation pour la durée restante de la location.',
|
||||
},
|
||||
{
|
||||
title: 'Article 9 : Litiges',
|
||||
body: 'Tout litige entre la société de location et le locataire relève exclusivement des tribunaux compétents du siège social de la société.',
|
||||
},
|
||||
],
|
||||
ar: [
|
||||
{
|
||||
title: 'البند 1 : استعمال السيارة',
|
||||
body: 'لا يسمح بسياقة السيارة إلا من طرف الأشخاص المحددة أسماؤهم في العقد.\n\nيمنع:\n- استعمال السيارة لأغراض غير مشروعة؛\n- نقل البضائع الممنوعة؛\n- جر العربات؛\n- نقل الأشخاص بمقابل؛\n- استعمال السيارات الخفيفة في الطرق غير المعبدة.',
|
||||
},
|
||||
{
|
||||
title: 'البند 2 : حالة السيارة',
|
||||
body: 'تُسلَّم السيارة في حالة جيدة من حيث النظافة والحالة الميكانيكية والكهربائية والعجلات، على أن تُعاد بنفس الحالة.\n\nالمسافة المسموح بها محددة في 400 كلم يومياً، وكل تجاوز لهذه المسافة يؤدي عنه المستأجر مبلغ 1.20 درهم عن كل كيلومتر إضافي.',
|
||||
},
|
||||
{
|
||||
title: 'البند 3 : الصيانة والإصلاح',
|
||||
body: 'يجب الحصول على موافقة وكالة التأجير قبل القيام بأي عملية صيانة أو إصلاح، سواء عن طريق الفاكس أو البريد الإلكتروني.\n\nكل عطب ناتج عن إهمال المستأجر يتحمل هو مصاريفه.',
|
||||
},
|
||||
{
|
||||
title: 'البند 4 : التأمين',
|
||||
body: '1. التأمين الشامل\nإذا ثبتت مسؤولية المستأجر في حادثة سير، فإنه يلتزم بأداء:\n- مبلغ التحمل المحدد من طرف شركة التأمين؛\n- المصاريف الناتجة عن توقف السيارة أثناء الإصلاح.\n\nويجب عليه إشعار وكالة التأجير فور وقوع الحادث.\n\n2. التأمين العادي\nيعتبر المستأجر المسؤول الوحيد عن حوادث السير، ويتحمل:\n- جميع مصاريف الإصلاح؛\n- تعويض فترة توقف السيارة عن العمل.',
|
||||
},
|
||||
{
|
||||
title: 'البند 5 : تمديد مدة الكراء',
|
||||
body: 'لا يمكن تمديد مدة الكراء إلا بموافقة وكالة التأجير.\n\nيجب على المستأجر إشعار الوكالة قبل يوم واحد على الأقل من التمديد.\n\nفي حالة التأخير عن موعد إرجاع السيارة لأكثر من ساعتين، يُحتسب يوم إضافي.\n\nتحتفظ الشركة بحق فسخ العقد دون تعويض إذا أخلّ المستأجر بأي بند من بنوده.\n\nفي حالة الإرجاع المبكر، لا يحق للمكتري المطالبة بأي تعويض أو استرجاع.\n\nوفي حالة عدم تمديد العقد رسمياً، يلتزم المستأجر بأداء 100 درهم عن كل ساعة تأخير إلى حين استرجاع السيارة.',
|
||||
},
|
||||
{
|
||||
title: 'البند 6 : أوراق السيارة',
|
||||
body: 'في حالة ضياع أوراق السيارة، يتحمل المستأجر جميع المصاريف المتعلقة بذلك، بما فيها:\n- مصاريف تجديد الوثائق؛\n- أيام توقف السيارة.',
|
||||
},
|
||||
{
|
||||
title: 'البند 7 : المسؤولية',
|
||||
body: 'يتحمل المستأجر:\n- الغرامات؛\n- المخالفات؛\n- جميع المحاضر المحررة ضده من طرف السلطات المختصة.\n\nكما يتحمل المسؤولية المالية عن الأضرار الناتجة عن القيادة تحت تأثير:\n- الكحول؛\n- المخدرات؛\n- الأدوية الممنوعة أثناء السياقة.\n\nويتعهد بعدم التخلي عن السيارة مهما كانت أسباب توقفها.',
|
||||
},
|
||||
{
|
||||
title: 'البند 8 : سقوط حق المكتري',
|
||||
body: 'تسقط كافة حقوق المكتري الممنوحة له بموجب هذا العقد في حالة عدم احترام أي بند من بنوده، دون المطالبة بأي تعويض عن المدة المتبقية.',
|
||||
},
|
||||
{
|
||||
title: 'البند 9 : المنازعات',
|
||||
body: 'تخضع جميع النزاعات بين شركة التأجير والمستأجر للاختصاص الحصري للمحاكم التابعة لمقر الشركة.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function normalizeContractLocale(value: string | null | undefined, fallback: ConditionLanguage): 'en' | 'en-ar' | 'fr' | 'fr-ar' | 'ar' {
|
||||
const normalized = String(value ?? '').trim().toLowerCase()
|
||||
if (normalized === 'en' || normalized === 'en-ar' || normalized === 'fr' || normalized === 'fr-ar' || normalized === 'ar') {
|
||||
return normalized
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
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']
|
||||
return ['ar']
|
||||
}
|
||||
|
||||
function getConditionLanguageLabel(language: ConditionLanguage) {
|
||||
if (language === 'fr') return 'Français'
|
||||
if (language === 'ar') return 'العربية'
|
||||
return 'English'
|
||||
}
|
||||
|
||||
function getAdditionalConditionTitle(language: ConditionLanguage, index: number) {
|
||||
if (language === 'fr') return `Clause supplémentaire ${index + 1}`
|
||||
if (language === 'ar') return `بند إضافي ${index + 1}`
|
||||
return `Additional clause ${index + 1}`
|
||||
}
|
||||
|
||||
function splitConditionItems(items: ConditionItem[]) {
|
||||
const midpoint = Math.ceil(items.length / 2)
|
||||
return [items.slice(0, midpoint), items.slice(midpoint)]
|
||||
}
|
||||
|
||||
function PaperField({
|
||||
label,
|
||||
value,
|
||||
@@ -254,7 +380,7 @@ export default function ContractDetailPage() {
|
||||
const [contract, setContract] = useState<ContractPayload | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const copy = {
|
||||
const copyByLanguage = {
|
||||
en: {
|
||||
back: 'Back to contracts',
|
||||
booking: 'Booking',
|
||||
@@ -313,8 +439,7 @@ export default function ContractDetailPage() {
|
||||
insuranceCondition: 'Insurance and vehicle condition',
|
||||
acknowledgementSignature: 'Driver acknowledgement and signature',
|
||||
vehicleInformation: 'Rental vehicle information',
|
||||
rentalRate: 'Car rental rate',
|
||||
billingSummary: 'Payment and billing summary',
|
||||
chargesAndFees: 'Fees and charges',
|
||||
damageDiagram: 'Vehicle damage diagram',
|
||||
customerName: 'Customer name',
|
||||
homeAddress: 'Home address',
|
||||
@@ -445,8 +570,7 @@ export default function ContractDetailPage() {
|
||||
insuranceCondition: 'Assurance et état du véhicule',
|
||||
acknowledgementSignature: 'Reconnaissance et signature',
|
||||
vehicleInformation: 'Informations véhicule',
|
||||
rentalRate: 'Tarification de location',
|
||||
billingSummary: 'Résumé de facturation',
|
||||
chargesAndFees: 'Frais et charges',
|
||||
damageDiagram: 'Schéma des dommages du véhicule',
|
||||
customerName: 'Nom du client',
|
||||
homeAddress: 'Adresse',
|
||||
@@ -577,8 +701,7 @@ export default function ContractDetailPage() {
|
||||
insuranceCondition: 'التأمين وحالة المركبة',
|
||||
acknowledgementSignature: 'إقرار السائق والتوقيع',
|
||||
vehicleInformation: 'معلومات مركبة التأجير',
|
||||
rentalRate: 'سعر التأجير',
|
||||
billingSummary: 'ملخص الفوترة',
|
||||
chargesAndFees: 'الرسوم والتكاليف',
|
||||
damageDiagram: 'مخطط أضرار المركبة',
|
||||
customerName: 'اسم العميل',
|
||||
homeAddress: 'العنوان',
|
||||
@@ -651,7 +774,7 @@ export default function ContractDetailPage() {
|
||||
fuelLevelLabels: { FULL: 'ممتلئ', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'فارغ' },
|
||||
paymentProviderLabels: { CASH: 'نقداً', STRIPE: 'Stripe', BANK_TRANSFER: 'تحويل بنكي', CHECK: 'شيك', PAYPAL: 'PayPal', CREDIT_CARD: 'بطاقة ائتمان', DEBIT_CARD: 'بطاقة خصم' },
|
||||
},
|
||||
}[language]
|
||||
} as const
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<ContractPayload>(`/reservations/${id}/contract`)
|
||||
@@ -662,6 +785,8 @@ export default function ContractDetailPage() {
|
||||
.catch((err) => setError(err.message ?? 'Failed to load contract'))
|
||||
}, [id])
|
||||
|
||||
const contractUiLanguage: ConditionLanguage = language
|
||||
const copy = copyByLanguage[contractUiLanguage]
|
||||
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
|
||||
const formatDateTime = (value: string | null | undefined) =>
|
||||
value
|
||||
@@ -684,22 +809,29 @@ export default function ContractDetailPage() {
|
||||
const tl = (map: Record<string, string>, key: string) => map[key] ?? key
|
||||
|
||||
const damagePoints = contract.inspections.checkIn?.damagePoints ?? []
|
||||
const rentalLine = contract.invoice.lineItems.find((item) => item.category === 'RENTAL') ?? contract.invoice.lineItems[0]
|
||||
const depositLine = contract.invoice.lineItems.find((item) => item.category === 'DEPOSIT')
|
||||
const additionalDriverLine = contract.invoice.lineItems.filter((item) => item.category === 'ADDITIONAL_DRIVER')
|
||||
const clauses = splitTerms(contract.terms.terms)
|
||||
const vehicleLabel = `${contract.vehicle.year} ${contract.vehicle.make} ${contract.vehicle.model}`
|
||||
const fallbackClauses = [
|
||||
copy.fallbackClauses.vehicleReservation(vehicleLabel),
|
||||
copy.fallbackClauses.authorizedUse,
|
||||
copy.fallbackClauses.fuel,
|
||||
copy.fallbackClauses.damage,
|
||||
copy.fallbackClauses.deposit,
|
||||
copy.fallbackClauses.lateFees,
|
||||
copy.fallbackClauses.paymentAuthorization(contract.company.name),
|
||||
copy.fallbackClauses.general,
|
||||
]
|
||||
const agreementClauses = clauses.length > 0 ? clauses : fallbackClauses
|
||||
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 arCopy = copyByLanguage.ar
|
||||
const bl = (primary: string, ar: string) => isBilingual ? `${primary} / ${ar}` : primary
|
||||
|
||||
const roadsidePhone = contract.company.phone ?? copy.none
|
||||
const latestPayment = contract.invoice.payments.at(-1)
|
||||
const driverFullName = `${contract.driver.firstName} ${contract.driver.lastName}`
|
||||
@@ -750,27 +882,27 @@ export default function ContractDetailPage() {
|
||||
{contract.company.name}
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-black tracking-tight">
|
||||
{contract.company.name} {copy.agreementTitle}
|
||||
{contract.company.name} {bl(copy.agreementTitle, arCopy.agreementTitle)}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm font-medium text-stone-600">
|
||||
{copy.agreementForReservation} {contract.contractNumber ?? contract.reservationId.slice(-8).toUpperCase()}
|
||||
{bl(copy.agreementForReservation, arCopy.agreementForReservation)} {contract.contractNumber ?? contract.reservationId.slice(-8).toUpperCase()}
|
||||
</p>
|
||||
</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">{copy.status}</p>
|
||||
<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">{copy.paymentStatus}</p>
|
||||
<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">{copy.contractNo}</p>
|
||||
<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">{copy.generated}</p>
|
||||
<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>
|
||||
@@ -779,61 +911,64 @@ export default function ContractDetailPage() {
|
||||
|
||||
<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={copy.customerInformation}>
|
||||
<PaperSection title={bl(copy.customerInformation, arCopy.customerInformation)}>
|
||||
<div className="grid gap-px bg-stone-300 sm:grid-cols-2">
|
||||
<PaperField label={copy.customerName} value={driverFullName} className="bg-white sm:col-span-2" />
|
||||
<PaperField label={copy.homeAddress} value={address || copy.none} className="bg-white sm:col-span-2" />
|
||||
<PaperField label={copy.cityCountry} value={[contract.company.city, contract.company.country].filter(Boolean).join(', ') || copy.none} className="bg-white" />
|
||||
<PaperField label={copy.telephone} value={contract.driver.phone ?? copy.none} className="bg-white" />
|
||||
<PaperField label={copy.emailLabel} value={contract.driver.email} className="bg-white sm:col-span-2" />
|
||||
<PaperField label={copy.driverLicenseNumber} value={contract.driver.driverLicense ?? copy.none} className="bg-white" />
|
||||
<PaperField label={copy.nationalityLabel} value={contract.driver.nationality ?? copy.none} className="bg-white" />
|
||||
<PaperField label={copy.birthDate} value={formatDateOnly(contract.driver.dateOfBirth, localeCode, copy.none)} className="bg-white" />
|
||||
<PaperField label={copy.issuedExpires} value={`${formatDateOnly(contract.driver.licenseIssuedAt, localeCode, copy.none)} — ${formatDateOnly(contract.driver.licenseExpiry, localeCode, copy.none)}`} className="bg-white" />
|
||||
<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>
|
||||
|
||||
<PaperSection title={copy.rentalPeriodDetails}>
|
||||
<PaperSection title={bl(copy.rentalPeriodDetails, arCopy.rentalPeriodDetails)}>
|
||||
<div className="grid gap-px bg-stone-300 sm:grid-cols-2">
|
||||
<PaperField label={copy.dateOut} value={formatDateTime(contract.rentalPeriod.startDate)} className="bg-white" />
|
||||
<PaperField label={copy.dateDueIn} value={formatDateTime(contract.rentalPeriod.endDate)} className="bg-white" />
|
||||
<PaperField label={copy.mileageOut} value={String(contract.inspections.checkIn?.mileage ?? copy.none)} className="bg-white" />
|
||||
<PaperField label={copy.mileageIn} value={String(contract.inspections.checkOut?.mileage ?? copy.none)} className="bg-white" />
|
||||
<PaperField label={copy.pickupLocationLabel} value={contract.rentalPeriod.pickupLocation ?? copy.none} className="bg-white" />
|
||||
<PaperField label={copy.returnLocationLabel} value={contract.rentalPeriod.returnLocation ?? copy.none} className="bg-white" />
|
||||
<PaperField label={copy.additionalDrivers} value={additionalDriverNames} className="bg-white sm:col-span-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={copy.insuranceCondition}>
|
||||
<PaperSection title={bl(copy.insuranceCondition, arCopy.insuranceCondition)}>
|
||||
<div className="grid gap-px bg-stone-300">
|
||||
<PaperField
|
||||
label={copy.insuranceSelections}
|
||||
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={copy.fuel} value={contract.terms.fuelPolicy ?? copy.none} className="bg-white" />
|
||||
<PaperField label={copy.damage} value={contract.terms.damagePolicy ?? copy.none} className="bg-white" />
|
||||
<PaperField label={bl(copy.fuel, arCopy.fuel)} value={contract.terms.fuelPolicy ?? copy.none} className="bg-white" />
|
||||
<PaperField label={bl(copy.damage, arCopy.damage)} value={contract.terms.damagePolicy ?? copy.none} className="bg-white" />
|
||||
<PaperField
|
||||
label={copy.checkInCondition}
|
||||
label={bl(copy.checkInCondition, arCopy.checkInCondition)}
|
||||
value={contract.inspections.checkIn?.generalCondition ?? contract.inspections.checkIn?.employeeNotes ?? copy.none}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
</PaperSection>
|
||||
|
||||
<PaperSection title={copy.acknowledgementSignature}>
|
||||
<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>
|
||||
{copy.signature}: <span className="font-semibold text-stone-900">{contract.terms.signatureRequired ? copy.yes : copy.no}</span>
|
||||
{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">
|
||||
{copy.customerSignature}
|
||||
{bl(copy.customerSignature, arCopy.customerSignature)}
|
||||
</div>
|
||||
<div className="border-t border-dashed border-stone-500 pt-2 text-sm font-medium text-stone-800">
|
||||
{copy.companyRepresentative}
|
||||
{bl(copy.companyRepresentative, arCopy.companyRepresentative)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -841,43 +976,37 @@ export default function ContractDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<PaperSection title={copy.vehicleInformation}>
|
||||
<PaperSection title={bl(copy.vehicleInformation, arCopy.vehicleInformation)}>
|
||||
<div className="grid gap-px bg-stone-300">
|
||||
<PaperField label={copy.yearMakeModel} value={`${contract.vehicle.year} ${contract.vehicle.make} ${contract.vehicle.model}`} className="bg-white" />
|
||||
<PaperField label={copy.licensePlateLabel} value={contract.vehicle.licensePlate} className="bg-white" />
|
||||
<PaperField label={copy.vehicleColor} value={contract.vehicle.color} className="bg-white" />
|
||||
<PaperField label={copy.categoryLabel} value={contract.vehicle.category} className="bg-white" />
|
||||
<PaperField label={copy.vinLabel} value={contract.vehicle.vin ?? copy.none} className="bg-white" />
|
||||
<div className="border border-stone-400/80 bg-white p-3">
|
||||
<p className="mb-3 text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{copy.damageDiagram}</p>
|
||||
<div className="flex justify-center">
|
||||
<VehicleDamageDiagram points={damagePoints} />
|
||||
<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-3 text-xs text-stone-600">
|
||||
<p className="mt-4 text-xs text-stone-600">
|
||||
{damagePoints.length > 0 ? copy.damageMarkersRecorded(damagePoints.length) : copy.noDamage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PaperSection>
|
||||
|
||||
<PaperSection title={copy.rentalRate}>
|
||||
<div className="grid gap-px bg-stone-300">
|
||||
<PaperField label={copy.dailyRate} value={rentalLine ? formatCurrency(rentalLine.unitPrice, contract.invoice.currency) : copy.none} className="bg-white" />
|
||||
<PaperField label={copy.rentalDays} value={String(contract.rentalPeriod.totalDays)} className="bg-white" />
|
||||
<PaperField label={copy.insuranceTotal} value={formatCurrency(contract.insurance.total, contract.invoice.currency)} className="bg-white" />
|
||||
<PaperField label={copy.additionalDriverFees} value={formatCurrency(additionalDriverLine.reduce((sum, item) => sum + item.total, 0), contract.invoice.currency)} className="bg-white" />
|
||||
<PaperField label={copy.deposit} value={depositLine ? formatCurrency(depositLine.total, contract.invoice.currency) : copy.none} className="bg-white" />
|
||||
<PaperField label={copy.subtotal} value={formatCurrency(contract.invoice.subtotal, contract.invoice.currency)} className="bg-white" />
|
||||
<PaperField label={copy.taxesLabel} value={contract.invoice.taxes.length > 0 ? contract.invoice.taxes.map((tax) => `${tax.label} ${tax.rate}%`).join(', ') : copy.none} className="bg-white" />
|
||||
<PaperField label={copy.totalChargeLabel} value={formatCurrency(contract.invoice.total, contract.invoice.currency)} className="bg-white" />
|
||||
<PaperField label={copy.amountPaidLabel} value={formatCurrency(contract.invoice.amountPaid, contract.invoice.currency)} className="bg-white" />
|
||||
<PaperField label={copy.balanceDueLabel} value={formatCurrency(contract.invoice.balanceDue, contract.invoice.currency)} className="bg-white" />
|
||||
<PaperField label={copy.paymentMode} value={contract.paymentMode ?? latestPayment?.paymentMethod ?? copy.none} className="bg-white" />
|
||||
</div>
|
||||
</PaperSection>
|
||||
|
||||
<PaperSection title={copy.billingSummary}>
|
||||
<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>
|
||||
@@ -887,9 +1016,39 @@ export default function ContractDetailPage() {
|
||||
<p className="font-semibold text-stone-900">{formatCurrency(item.total, contract.invoice.currency)}</p>
|
||||
</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">
|
||||
{copy.lastPayment}: {tl(copy.paymentProviderLabels, latestPayment.provider)} · {tl(copy.paymentStatusLabels, latestPayment.status)} · {formatDateTime(latestPayment.paidAt ?? latestPayment.createdAt)}
|
||||
{bl(copy.lastPayment, arCopy.lastPayment)}: {tl(copy.paymentProviderLabels, latestPayment.provider)} · {tl(copy.paymentStatusLabels, latestPayment.status)} · {formatDateTime(latestPayment.paidAt ?? latestPayment.createdAt)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -898,55 +1057,49 @@ export default function ContractDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="border-t border-stone-500 px-6 py-4 text-center text-sm text-stone-700">
|
||||
{copy.roadsideAssistance} <span className="font-semibold text-stone-900">{roadsidePhone}</span>
|
||||
{bl(copy.roadsideAssistance, arCopy.roadsideAssistance)} <span className="font-semibold text-stone-900">{roadsidePhone}</span>
|
||||
</div>
|
||||
</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">{copy.termsAndConditions}</h2>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 md:grid-cols-2 print:grid-cols-2">
|
||||
{agreementClauses.map((clause, index) => (
|
||||
<div key={`${index}-${clause.slice(0, 24)}`} className="text-sm leading-6 text-stone-700">
|
||||
<p className="font-bold text-stone-900">
|
||||
{index + 1}. {copy.clauseHeadings[index] ?? copy.clauseHeadings[copy.clauseHeadings.length - 1]}
|
||||
</p>
|
||||
<p className="mt-2 whitespace-pre-wrap">{clause}</p>
|
||||
</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>
|
||||
{column.clauses.map((clause, index) => (
|
||||
<div key={`${column.language}-${index}-${clause.title}`} className="text-sm leading-6 text-stone-700">
|
||||
<p className="font-bold text-stone-900">{clause.title}</p>
|
||||
<p className="mt-2 whitespace-pre-wrap">{clause.body}</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">
|
||||
{columnClauses.map((clause, index) => (
|
||||
<div key={`${columnIndex}-${index}-${clause.title}`} className="text-sm leading-6 text-stone-700">
|
||||
<p className="font-bold text-stone-900">{clause.title}</p>
|
||||
<p className="mt-2 whitespace-pre-wrap">{clause.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 grid gap-5 lg:grid-cols-2 print:grid-cols-2">
|
||||
<PaperSection title={copy.inspections}>
|
||||
<div className="grid gap-px bg-stone-300">
|
||||
<PaperField label={copy.checkInMileageFuel} value={`${contract.inspections.checkIn?.mileage ?? copy.none} / ${contract.inspections.checkIn?.fuelLevel ? tl(copy.fuelLevelLabels, contract.inspections.checkIn.fuelLevel) : copy.none}`} className="bg-white" />
|
||||
<PaperField label={copy.checkOutMileageFuel} value={`${contract.inspections.checkOut?.mileage ?? copy.none} / ${contract.inspections.checkOut?.fuelLevel ? tl(copy.fuelLevelLabels, contract.inspections.checkOut.fuelLevel) : copy.none}`} className="bg-white" />
|
||||
<PaperField label={copy.damageEntries} value={damagePoints.length > 0 ? damagePoints.map((point) => `${tl(copy.damageTypeLabels, point.damageType)} (${tl(copy.severityLabels, point.severity)})`).join(', ') : copy.noDamage} className="bg-white" />
|
||||
</div>
|
||||
</PaperSection>
|
||||
|
||||
<PaperSection title={copy.notesAndFooter}>
|
||||
<div className="space-y-3 bg-white p-4 text-sm text-stone-700">
|
||||
<div>
|
||||
<p className="font-semibold text-stone-900">{copy.notes}</p>
|
||||
<p className="mt-1 whitespace-pre-wrap">{contract.notes || copy.none}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-stone-900">{copy.footer}</p>
|
||||
<p className="mt-1 whitespace-pre-wrap">{contract.terms.footerNote || copy.none}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-stone-900">{copy.payments}</p>
|
||||
<p className="mt-1">{contract.invoice.payments.length > 0 ? copy.paymentsRecorded(contract.invoice.payments.length) : copy.noPayments}</p>
|
||||
</div>
|
||||
</div>
|
||||
</PaperSection>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
@@ -20,6 +19,15 @@ interface ReservationDetail {
|
||||
additionalDriverTotal: number
|
||||
pricingRulesTotal: number
|
||||
paymentStatus: string
|
||||
depositAmount: number
|
||||
pickupLocation: string | null
|
||||
returnLocation: string | null
|
||||
paymentMode: string | null
|
||||
notes: string | null
|
||||
contractNumber: string | null
|
||||
invoiceNumber: string | null
|
||||
damageChargeAmount: number | null
|
||||
damageChargeNote: string | null
|
||||
pricingRulesApplied: { name: string; amount: number; type: string }[] | null
|
||||
customer: {
|
||||
firstName: string
|
||||
@@ -46,16 +54,175 @@ interface ReservationDetail {
|
||||
approvedAt: string | null
|
||||
approvalNote: string | null
|
||||
}[]
|
||||
workflow: {
|
||||
contractGenerated: boolean
|
||||
closed: boolean
|
||||
closedAt: string | null
|
||||
closedBy: string | null
|
||||
coreEditable: boolean
|
||||
returnEditable: boolean
|
||||
checkInInspectionEditable: boolean
|
||||
checkOutInspectionEditable: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type EditMode = 'booking' | 'return' | null
|
||||
|
||||
type ReservationFormState = {
|
||||
startDate: string
|
||||
endDate: string
|
||||
pickupLocation: string
|
||||
returnLocation: string
|
||||
depositAmount: string
|
||||
paymentMode: string
|
||||
notes: string
|
||||
damageChargeAmount: string
|
||||
damageChargeNote: string
|
||||
}
|
||||
|
||||
const detailCopy = {
|
||||
en: {
|
||||
editBooking: 'Edit booking',
|
||||
saveBooking: 'Save booking',
|
||||
editReturn: 'Edit return',
|
||||
saveReturn: 'Save return',
|
||||
cancelEdit: 'Cancel',
|
||||
closeReservation: 'Close reservation',
|
||||
bookingDetails: 'Booking details',
|
||||
returnDetails: 'Return closing',
|
||||
startLabel: 'Start date & time',
|
||||
endLabel: 'End date & time',
|
||||
pickupLabel: 'Pickup location',
|
||||
returnLabel: 'Return location',
|
||||
depositLabel: 'Deposit',
|
||||
paymentModeLabel: 'Payment mode',
|
||||
bookingNotesLabel: 'Booking notes',
|
||||
returnChargeLabel: 'Damage charge',
|
||||
returnChargeNoteLabel: 'Return note',
|
||||
paymentModeEmpty: 'Not set',
|
||||
noLocation: 'Not provided',
|
||||
failedSave: 'Failed to save reservation',
|
||||
failedClose: 'Failed to close reservation',
|
||||
contractLockMessage: 'Contract generated. Booking details are locked and remain view-only until the vehicle is returned.',
|
||||
activeLockMessage: 'Rental is in progress. Booking details are view-only until the vehicle is returned.',
|
||||
returnLockMessage: 'Vehicle returned. Only the return section and the check-out inspection can still be edited before closing the reservation.',
|
||||
closedLockMessage: 'Reservation closed. No further editing is allowed.',
|
||||
closedMeta: 'Closed',
|
||||
checkoutSummary: 'Check-out status',
|
||||
checkoutPending: 'Waiting for return edits',
|
||||
checkoutReady: 'Return section open',
|
||||
checkoutClosed: 'Reservation closed',
|
||||
checkInReadOnly: 'Check-in inspection is only editable after the reservation is confirmed and before return.',
|
||||
checkOutReadOnly: 'Check-out inspection becomes editable once the vehicle is returned.',
|
||||
inspectionClosed: 'This reservation is closed. Inspection edits are disabled.',
|
||||
},
|
||||
fr: {
|
||||
editBooking: 'Modifier la réservation',
|
||||
saveBooking: 'Enregistrer la réservation',
|
||||
editReturn: 'Modifier le retour',
|
||||
saveReturn: 'Enregistrer le retour',
|
||||
cancelEdit: 'Annuler',
|
||||
closeReservation: 'Clôturer la réservation',
|
||||
bookingDetails: 'Détails de la réservation',
|
||||
returnDetails: 'Clôture du retour',
|
||||
startLabel: 'Date et heure de départ',
|
||||
endLabel: 'Date et heure de retour',
|
||||
pickupLabel: 'Lieu de départ',
|
||||
returnLabel: 'Lieu de retour',
|
||||
depositLabel: 'Dépôt',
|
||||
paymentModeLabel: 'Mode de paiement',
|
||||
bookingNotesLabel: 'Notes de réservation',
|
||||
returnChargeLabel: 'Frais de dommage',
|
||||
returnChargeNoteLabel: 'Note de retour',
|
||||
paymentModeEmpty: 'Non défini',
|
||||
noLocation: 'Non renseigné',
|
||||
failedSave: 'Échec de la sauvegarde de la réservation',
|
||||
failedClose: 'Échec de la clôture de la réservation',
|
||||
contractLockMessage: 'Le contrat a été généré. Les détails de la réservation sont verrouillés jusqu’au retour du véhicule.',
|
||||
activeLockMessage: 'La location est en cours. Les détails de la réservation sont en lecture seule jusqu’au retour du véhicule.',
|
||||
returnLockMessage: 'Le véhicule est de retour. Seule la section de retour et l’inspection de retour peuvent encore être modifiées avant la clôture.',
|
||||
closedLockMessage: 'La réservation est clôturée. Aucune autre modification n’est autorisée.',
|
||||
closedMeta: 'Clôturée',
|
||||
checkoutSummary: 'Statut du retour',
|
||||
checkoutPending: 'En attente des modifications de retour',
|
||||
checkoutReady: 'Section retour ouverte',
|
||||
checkoutClosed: 'Réservation clôturée',
|
||||
checkInReadOnly: 'L’inspection de départ est modifiable après confirmation et avant le retour.',
|
||||
checkOutReadOnly: 'L’inspection de retour devient modifiable une fois le véhicule retourné.',
|
||||
inspectionClosed: 'Cette réservation est clôturée. Les modifications d’inspection sont désactivées.',
|
||||
},
|
||||
ar: {
|
||||
editBooking: 'تعديل الحجز',
|
||||
saveBooking: 'حفظ الحجز',
|
||||
editReturn: 'تعديل الإرجاع',
|
||||
saveReturn: 'حفظ الإرجاع',
|
||||
cancelEdit: 'إلغاء',
|
||||
closeReservation: 'إغلاق الحجز',
|
||||
bookingDetails: 'تفاصيل الحجز',
|
||||
returnDetails: 'إقفال الإرجاع',
|
||||
startLabel: 'تاريخ ووقت البداية',
|
||||
endLabel: 'تاريخ ووقت النهاية',
|
||||
pickupLabel: 'موقع الاستلام',
|
||||
returnLabel: 'موقع الإرجاع',
|
||||
depositLabel: 'العربون',
|
||||
paymentModeLabel: 'طريقة الدفع',
|
||||
bookingNotesLabel: 'ملاحظات الحجز',
|
||||
returnChargeLabel: 'رسوم الأضرار',
|
||||
returnChargeNoteLabel: 'ملاحظة الإرجاع',
|
||||
paymentModeEmpty: 'غير محدد',
|
||||
noLocation: 'غير متوفر',
|
||||
failedSave: 'فشل حفظ الحجز',
|
||||
failedClose: 'فشل إغلاق الحجز',
|
||||
contractLockMessage: 'تم إنشاء العقد. تفاصيل الحجز مقفلة وتبقى للعرض فقط حتى عودة السيارة.',
|
||||
activeLockMessage: 'التأجير قيد التنفيذ. تفاصيل الحجز للعرض فقط حتى عودة السيارة.',
|
||||
returnLockMessage: 'تمت إعادة السيارة. يمكن تعديل قسم الإرجاع وفحص الإرجاع فقط قبل إغلاق الحجز.',
|
||||
closedLockMessage: 'تم إغلاق الحجز. لا يُسمح بأي تعديل إضافي.',
|
||||
closedMeta: 'مغلق',
|
||||
checkoutSummary: 'حالة الإرجاع',
|
||||
checkoutPending: 'بانتظار تعديلات الإرجاع',
|
||||
checkoutReady: 'قسم الإرجاع مفتوح',
|
||||
checkoutClosed: 'تم إغلاق الحجز',
|
||||
checkInReadOnly: 'يمكن تعديل فحص الاستلام بعد تأكيد الحجز وقبل الإرجاع فقط.',
|
||||
checkOutReadOnly: 'يصبح فحص الإرجاع قابلاً للتعديل بعد عودة السيارة.',
|
||||
inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.',
|
||||
},
|
||||
} as const
|
||||
|
||||
function toDateTimeInputValue(iso: string) {
|
||||
const date = new Date(iso)
|
||||
const offset = date.getTimezoneOffset()
|
||||
return new Date(date.getTime() - offset * 60_000).toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function toFormState(reservation: ReservationDetail): ReservationFormState {
|
||||
return {
|
||||
startDate: toDateTimeInputValue(reservation.startDate),
|
||||
endDate: toDateTimeInputValue(reservation.endDate),
|
||||
pickupLocation: reservation.pickupLocation ?? '',
|
||||
returnLocation: reservation.returnLocation ?? '',
|
||||
depositAmount: String(reservation.depositAmount ?? 0),
|
||||
paymentMode: reservation.paymentMode ?? '',
|
||||
notes: reservation.notes ?? '',
|
||||
damageChargeAmount: reservation.damageChargeAmount !== null && reservation.damageChargeAmount !== undefined ? String(reservation.damageChargeAmount) : '',
|
||||
damageChargeNote: reservation.damageChargeNote ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function toIsoString(value: string) {
|
||||
return new Date(value).toISOString()
|
||||
}
|
||||
|
||||
export default function ReservationDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const { dict, language } = useDashboardI18n()
|
||||
const r = dict.reservations
|
||||
const copy = detailCopy[language]
|
||||
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
|
||||
|
||||
const [reservation, setReservation] = useState<ReservationDetail | null>(null)
|
||||
const [inspections, setInspections] = useState<DamageInspection[]>([])
|
||||
const [form, setForm] = useState<ReservationFormState | null>(null)
|
||||
const [editMode, setEditMode] = useState<EditMode>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const [acting, setActing] = useState(false)
|
||||
@@ -68,6 +235,8 @@ export default function ReservationDetailPage() {
|
||||
])
|
||||
setReservation(reservationData)
|
||||
setInspections(inspectionData)
|
||||
setForm(toFormState(reservationData))
|
||||
setEditMode(null)
|
||||
setError(null)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? r.failedToLoad)
|
||||
@@ -95,6 +264,56 @@ export default function ReservationDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveReservation(mode: Exclude<EditMode, null>) {
|
||||
if (!form) return
|
||||
|
||||
setActing(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
const payload = mode === 'booking'
|
||||
? {
|
||||
startDate: toIsoString(form.startDate),
|
||||
endDate: toIsoString(form.endDate),
|
||||
pickupLocation: form.pickupLocation || null,
|
||||
returnLocation: form.returnLocation || null,
|
||||
depositAmount: Number(form.depositAmount || 0),
|
||||
paymentMode: form.paymentMode || null,
|
||||
notes: form.notes || null,
|
||||
}
|
||||
: {
|
||||
returnLocation: form.returnLocation || null,
|
||||
damageChargeAmount: form.damageChargeAmount ? Number(form.damageChargeAmount) : 0,
|
||||
damageChargeNote: form.damageChargeNote || null,
|
||||
}
|
||||
|
||||
await apiFetch(`/reservations/${params.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
await loadReservation()
|
||||
} catch (err: any) {
|
||||
setActionError(err.message ?? copy.failedSave)
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function closeReservation() {
|
||||
setActing(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
await apiFetch(`/reservations/${params.id}/close`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
await loadReservation()
|
||||
} catch (err: any) {
|
||||
setActionError(err.message ?? copy.failedClose)
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function approveDriver(driverId: string) {
|
||||
setActing(true)
|
||||
setActionError(null)
|
||||
@@ -115,10 +334,25 @@ 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) return <div className="card p-6 text-sm text-slate-500">{r.loadingReservation}</div>
|
||||
if (!reservation || !form) return <div className="card p-6 text-sm text-slate-500">{r.loadingReservation}</div>
|
||||
|
||||
const checkinInspection = inspections.find((i) => i.type === 'CHECKIN')
|
||||
const checkoutInspection = inspections.find((i) => i.type === 'CHECKOUT')
|
||||
const checkinInspection = inspections.find((inspection) => inspection.type === 'CHECKIN')
|
||||
const checkoutInspection = inspections.find((inspection) => inspection.type === 'CHECKOUT')
|
||||
const bookingInputsDisabled = editMode !== 'booking'
|
||||
const returnInputsDisabled = editMode !== 'return'
|
||||
|
||||
let workflowMessage = ''
|
||||
if (reservation.workflow.closed) workflowMessage = copy.closedLockMessage
|
||||
else if (reservation.workflow.returnEditable) workflowMessage = copy.returnLockMessage
|
||||
else if (reservation.workflow.contractGenerated) workflowMessage = copy.contractLockMessage
|
||||
else if (reservation.status === 'ACTIVE') workflowMessage = copy.activeLockMessage
|
||||
|
||||
const checkInReadOnlyMessage = reservation.workflow.closed
|
||||
? copy.inspectionClosed
|
||||
: copy.checkInReadOnly
|
||||
const checkOutReadOnlyMessage = reservation.workflow.closed
|
||||
? copy.inspectionClosed
|
||||
: copy.checkOutReadOnly
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -126,11 +360,48 @@ export default function ReservationDetailPage() {
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Reservation {reservation.id.slice(-8).toUpperCase()}</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">{reservation.source} · {reservation.status} · {reservation.paymentStatus}</p>
|
||||
{reservation.workflow.closedAt && (
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
{copy.closedMeta} {formatDate(reservation.workflow.closedAt)}{reservation.workflow.closedBy ? ` · ${reservation.workflow.closedBy}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={`/dashboard/contracts/${reservation.id}`} className="btn-secondary">
|
||||
{language === 'fr' ? 'Contrat' : language === 'ar' ? 'العقد' : 'Contract'}
|
||||
</Link>
|
||||
{editMode === null && reservation.workflow.coreEditable && (
|
||||
<button disabled={acting} onClick={() => setEditMode('booking')} className="btn-secondary">
|
||||
{copy.editBooking}
|
||||
</button>
|
||||
)}
|
||||
{editMode === 'booking' && (
|
||||
<>
|
||||
<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">
|
||||
{copy.cancelEdit}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{editMode === null && reservation.workflow.returnEditable && (
|
||||
<>
|
||||
<button disabled={acting} onClick={() => setEditMode('return')} className="btn-secondary">
|
||||
{copy.editReturn}
|
||||
</button>
|
||||
<button disabled={acting} onClick={closeReservation} className="btn-primary">
|
||||
{acting ? r.working : copy.closeReservation}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{editMode === 'return' && (
|
||||
<>
|
||||
<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">
|
||||
{copy.cancelEdit}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{reservation.status === 'DRAFT' && (
|
||||
<button disabled={acting} onClick={() => runAction('confirm')} className="btn-primary">
|
||||
{acting ? r.working : r.confirmReservation}
|
||||
@@ -149,6 +420,12 @@ export default function ReservationDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{workflowMessage && (
|
||||
<div className="card border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
{workflowMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionError && <div className="card p-4 text-sm text-red-600">{actionError}</div>}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
@@ -172,12 +449,129 @@ export default function ReservationDetailPage() {
|
||||
<p className="font-medium text-slate-900">{reservation.vehicle.make} {reservation.vehicle.model}</p>
|
||||
<p>{reservation.vehicle.licensePlate}</p>
|
||||
<p>{formatDate(reservation.startDate)} - {formatDate(reservation.endDate)}</p>
|
||||
<p>{copy.paymentModeLabel}: <span className="font-medium text-slate-900">{reservation.paymentMode || copy.paymentModeEmpty}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="space-y-6">
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">{copy.bookingDetails}</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.startLabel}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
|
||||
value={form.startDate}
|
||||
disabled={bookingInputsDisabled}
|
||||
onChange={(e) => setForm((current) => current ? { ...current, startDate: e.target.value } : current)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.endLabel}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
|
||||
value={form.endDate}
|
||||
disabled={bookingInputsDisabled}
|
||||
onChange={(e) => setForm((current) => current ? { ...current, endDate: e.target.value } : current)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.pickupLabel}</label>
|
||||
<input
|
||||
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
|
||||
value={form.pickupLocation}
|
||||
disabled={bookingInputsDisabled}
|
||||
onChange={(e) => setForm((current) => current ? { ...current, pickupLocation: e.target.value } : current)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.returnLabel}</label>
|
||||
<input
|
||||
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
|
||||
value={form.returnLocation}
|
||||
disabled={bookingInputsDisabled}
|
||||
onChange={(e) => setForm((current) => current ? { ...current, returnLocation: e.target.value } : current)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.depositLabel}</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
|
||||
value={form.depositAmount}
|
||||
disabled={bookingInputsDisabled}
|
||||
onChange={(e) => setForm((current) => current ? { ...current, depositAmount: e.target.value } : current)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.paymentModeLabel}</label>
|
||||
<input
|
||||
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
|
||||
value={form.paymentMode}
|
||||
disabled={bookingInputsDisabled}
|
||||
onChange={(e) => setForm((current) => current ? { ...current, paymentMode: e.target.value } : current)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.bookingNotesLabel}</label>
|
||||
<textarea
|
||||
className="input-field min-h-24 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
|
||||
value={form.notes}
|
||||
disabled={bookingInputsDisabled}
|
||||
onChange={(e) => setForm((current) => current ? { ...current, notes: e.target.value } : current)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">{copy.returnDetails}</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.returnLabel}</label>
|
||||
<input
|
||||
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
|
||||
value={form.returnLocation}
|
||||
disabled={returnInputsDisabled}
|
||||
onChange={(e) => setForm((current) => current ? { ...current, returnLocation: e.target.value } : current)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.returnChargeLabel}</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
|
||||
value={form.damageChargeAmount}
|
||||
disabled={returnInputsDisabled}
|
||||
onChange={(e) => setForm((current) => current ? { ...current, damageChargeAmount: e.target.value } : current)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.returnChargeNoteLabel}</label>
|
||||
<textarea
|
||||
className="input-field min-h-24 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
|
||||
value={form.damageChargeNote}
|
||||
disabled={returnInputsDisabled}
|
||||
onChange={(e) => setForm((current) => current ? { ...current, damageChargeNote: e.target.value } : current)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 rounded-2xl border border-slate-200 px-4 py-3 text-sm text-slate-600">
|
||||
<span className="font-medium text-slate-900">{copy.checkoutSummary}:</span>{' '}
|
||||
{reservation.workflow.closed
|
||||
? copy.checkoutClosed
|
||||
: reservation.workflow.returnEditable
|
||||
? copy.checkoutReady
|
||||
: copy.checkoutPending}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
@@ -186,6 +580,7 @@ export default function ReservationDetailPage() {
|
||||
<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><dt className="text-slate-500">{copy.returnChargeLabel}</dt><dd className="text-slate-900">{formatCurrency(reservation.damageChargeAmount ?? 0, 'MAD')}</dd></div>
|
||||
</dl>
|
||||
|
||||
{reservation.insurances.length > 0 && (
|
||||
@@ -223,12 +618,17 @@ export default function ReservationDetailPage() {
|
||||
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"
|
||||
initialInspection={checkoutInspection}
|
||||
comparisonPoints={checkinInspection?.damagePoints ?? []}
|
||||
editable={reservation.workflow.checkOutInspectionEditable}
|
||||
readOnlyMessage={checkOutReadOnlyMessage}
|
||||
onSaved={(inspection) => setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])}
|
||||
/>
|
||||
</div>
|
||||
@@ -275,6 +675,10 @@ export default function ReservationDetailPage() {
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,12 @@ interface ReservationRow {
|
||||
totalAmount: number
|
||||
totalDays: number
|
||||
contractNumber?: string | null
|
||||
workflow?: {
|
||||
contractGenerated: boolean
|
||||
closed: boolean
|
||||
coreEditable: boolean
|
||||
returnEditable: boolean
|
||||
}
|
||||
vehicle: { make: string; model: string }
|
||||
customer: { firstName: string; lastName: string; email: string }
|
||||
}
|
||||
@@ -37,6 +43,15 @@ export default function ReservationsPage() {
|
||||
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
|
||||
const formatDateYear = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
const reservationActionLabel = (row: ReservationRow) => {
|
||||
if (row.workflow?.returnEditable) {
|
||||
return language === 'fr' ? 'Retour / clôture' : language === 'ar' ? 'الإرجاع / الإغلاق' : 'Return / close'
|
||||
}
|
||||
if (row.workflow?.coreEditable) {
|
||||
return language === 'fr' ? 'Modifier la réservation' : language === 'ar' ? 'تعديل الحجز' : 'Edit reservation'
|
||||
}
|
||||
return language === 'fr' ? 'Voir la réservation' : language === 'ar' ? 'عرض الحجز' : 'View reservation'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -73,10 +88,8 @@ export default function ReservationsPage() {
|
||||
{row.customer.firstName} {row.customer.lastName}
|
||||
</Link>
|
||||
<p className="text-xs text-slate-500">{row.customer.email}</p>
|
||||
<Link href={`/dashboard/contracts/${row.id}`} className="mt-1 inline-block text-xs font-semibold text-blue-700 hover:underline">
|
||||
{row.contractNumber
|
||||
? (language === 'fr' ? 'Voir le contrat' : language === 'ar' ? 'عرض العقد' : 'View contract')
|
||||
: (language === 'fr' ? 'Générer le contrat' : language === 'ar' ? 'إنشاء العقد' : 'Generate contract')}
|
||||
<Link href={`/dashboard/reservations/${row.id}`} className="mt-1 inline-block text-xs font-semibold text-blue-700 hover:underline">
|
||||
{reservationActionLabel(row)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.vehicle.make} {row.vehicle.model}</td>
|
||||
|
||||
@@ -25,6 +25,7 @@ interface BrandSettings {
|
||||
paypalMerchantId?: string | null
|
||||
customDomain?: string | null
|
||||
customDomainVerified?: boolean
|
||||
defaultLocale?: string
|
||||
defaultCurrency?: string
|
||||
isListedOnMarketplace: boolean
|
||||
}
|
||||
@@ -100,6 +101,7 @@ export default function SettingsPage() {
|
||||
city: 'City',
|
||||
country: 'Country',
|
||||
websiteUrl: 'Website URL',
|
||||
contractLanguage: 'Contract language',
|
||||
whatsapp: 'WhatsApp number',
|
||||
brandColor: 'Brand color',
|
||||
listedMarketplace: 'Listed on marketplace',
|
||||
@@ -174,6 +176,7 @@ export default function SettingsPage() {
|
||||
city: 'Ville',
|
||||
country: 'Pays',
|
||||
websiteUrl: 'URL du site',
|
||||
contractLanguage: 'Langue du contrat',
|
||||
whatsapp: 'Numéro WhatsApp',
|
||||
brandColor: 'Couleur de marque',
|
||||
listedMarketplace: 'Listé sur la marketplace',
|
||||
@@ -248,6 +251,7 @@ export default function SettingsPage() {
|
||||
city: 'المدينة',
|
||||
country: 'الدولة',
|
||||
websiteUrl: 'رابط الموقع',
|
||||
contractLanguage: 'لغة العقد',
|
||||
whatsapp: 'رقم واتساب',
|
||||
brandColor: 'لون العلامة',
|
||||
listedMarketplace: 'مدرج في السوق',
|
||||
@@ -360,6 +364,7 @@ export default function SettingsPage() {
|
||||
publicCity: brand.publicCity || undefined,
|
||||
publicCountry: brand.publicCountry || undefined,
|
||||
websiteUrl: brand.websiteUrl || undefined,
|
||||
defaultLocale: brand.defaultLocale || undefined,
|
||||
whatsappNumber: brand.whatsappNumber || undefined,
|
||||
paypalEmail: brand.paypalEmail || undefined,
|
||||
amanpayMerchantId: brand.amanpayMerchantId || undefined,
|
||||
@@ -572,6 +577,14 @@ export default function SettingsPage() {
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.websiteUrl}</label>
|
||||
<input className="input-field" value={brand.websiteUrl ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, websiteUrl: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.contractLanguage}</label>
|
||||
<select className="input-field" value={brand.defaultLocale ?? 'en'} onChange={(event) => setBrand((current) => current ? { ...current, defaultLocale: event.target.value } : current)}>
|
||||
<option value="en">EN</option>
|
||||
<option value="fr">FR</option>
|
||||
<option value="ar">AR</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.whatsapp}</label>
|
||||
<input className="input-field" value={brand.whatsappNumber ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, whatsappNumber: event.target.value } : current)} />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
@@ -14,9 +15,15 @@ type SignupForm = {
|
||||
password: string
|
||||
confirmPassword: string
|
||||
companyName: string
|
||||
companyPhone: string
|
||||
legalForm: string
|
||||
registrationNumber: string
|
||||
streetAddress: string
|
||||
city: string
|
||||
country: string
|
||||
zipCode: string
|
||||
companyPhone: string
|
||||
fax: string
|
||||
yearsActive: string
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'
|
||||
billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
currency: 'MAD' | 'USD' | 'EUR'
|
||||
@@ -42,7 +49,11 @@ type SignupResponse = {
|
||||
} | null
|
||||
}
|
||||
|
||||
const LEGAL_FORM_OPTIONS = ['SARL', 'SARL AU', 'SA', 'SAS', 'AUTO_ENTREPRENEUR', 'EI', 'OTHER'] as const
|
||||
const YEARS_ACTIVE_OPTIONS = ['LESS_THAN_1', 'ONE_TO_FIVE', 'MORE_THAN_5'] as const
|
||||
|
||||
export default function SignUpPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const searchParams = useSearchParams()
|
||||
const initialPlan = normalizePlan(searchParams.get('plan'))
|
||||
const initialBillingPeriod = normalizeBillingPeriod(searchParams.get('billing'))
|
||||
@@ -58,23 +69,266 @@ export default function SignUpPage() {
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
companyName: '',
|
||||
companyPhone: '',
|
||||
legalForm: '',
|
||||
registrationNumber: '',
|
||||
streetAddress: '',
|
||||
city: '',
|
||||
country: '',
|
||||
zipCode: '',
|
||||
companyPhone: '',
|
||||
fax: '',
|
||||
yearsActive: '',
|
||||
plan: initialPlan,
|
||||
billingPeriod: initialBillingPeriod,
|
||||
currency: initialCurrency,
|
||||
paymentProvider: 'AMANPAY',
|
||||
})
|
||||
|
||||
const isArabic = language === 'ar'
|
||||
const dict = {
|
||||
en: {
|
||||
brand: 'RentalDriveGo',
|
||||
pageTitle: 'Launch your rental workspace',
|
||||
pageSubtitle: 'Create the user account, complete the company information, and launch your workspace.',
|
||||
steps: ['Account User', 'Company info', 'Plan', 'Verify'],
|
||||
ownerTitle: 'Account User',
|
||||
ownerBody: 'Create the primary user account that will manage this company workspace.',
|
||||
partnerTitle: 'Company info',
|
||||
partnerBody: 'Complete the company information required to create the workspace.',
|
||||
planTitle: 'Choose your plan',
|
||||
planBody: 'Your workspace starts immediately on a 14-day free trial in your preferred billing currency.',
|
||||
reviewTitle: 'Review and launch',
|
||||
reviewBody: 'We will create the company workspace immediately and start the 14-day trial with the plan you selected.',
|
||||
firstName: 'Manager/Owner first name',
|
||||
lastName: 'Manager/Owner last name',
|
||||
ownerEmail: 'Manager/Owner email',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordHint: 'Use at least 8 characters for your company owner account.',
|
||||
companyName: 'Company Name',
|
||||
legalForm: 'Legal form',
|
||||
registrationNumber: 'Commercial Registry (RC)',
|
||||
streetAddress: 'Street Address',
|
||||
city: 'City',
|
||||
country: 'Country',
|
||||
zipCode: 'Zip code',
|
||||
companyPhone: 'Phone',
|
||||
fax: 'Fax (optional)',
|
||||
yearsActive: 'Years active',
|
||||
continue: 'Continue',
|
||||
back: 'Back',
|
||||
working: 'Working…',
|
||||
createWorkspace: 'Create workspace',
|
||||
workspaceReady: 'Workspace ready',
|
||||
workspaceReadyBody: 'is now active and the owner email',
|
||||
enterDashboard: 'Enter dashboard',
|
||||
signInAnotherDevice: 'Sign in on another device',
|
||||
reviewWorkspace: 'Workspace',
|
||||
reviewPlan: 'Plan',
|
||||
reviewOwnerEmail: 'Owner email',
|
||||
reviewPhone: 'Phone',
|
||||
invalidPassword: 'Choose a password with at least 8 characters.',
|
||||
passwordMismatch: 'Passwords do not match.',
|
||||
missingRequired: 'Fill in all required fields.',
|
||||
companyFallback: 'Your company',
|
||||
couldNotCreate: 'Could not create workspace',
|
||||
legalFormOptions: {
|
||||
'': 'Select legal form',
|
||||
SARL: 'SARL',
|
||||
'SARL AU': 'SARL AU',
|
||||
SA: 'SA',
|
||||
SAS: 'SAS',
|
||||
AUTO_ENTREPRENEUR: 'Auto-entrepreneur',
|
||||
EI: 'Sole proprietorship',
|
||||
OTHER: 'Other',
|
||||
} as Record<string, string>,
|
||||
yearsActiveOptions: {
|
||||
'': 'Select years active',
|
||||
LESS_THAN_1: 'Less than 1 year',
|
||||
ONE_TO_FIVE: '1 to 5 years',
|
||||
MORE_THAN_5: 'More than 5 years',
|
||||
} as Record<string, string>,
|
||||
planDescriptions: {
|
||||
STARTER: 'Core fleet, offers, and bookings.',
|
||||
GROWTH: 'Featured marketplace placement and more room to scale.',
|
||||
PRO: 'Full white-label and premium controls.',
|
||||
} as Record<SignupForm['plan'], string>,
|
||||
billingPeriodOptions: {
|
||||
MONTHLY: 'Monthly',
|
||||
ANNUAL: 'Annual',
|
||||
} as Record<SignupForm['billingPeriod'], string>,
|
||||
paymentProviderOptions: {
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<SignupForm['paymentProvider'], string>,
|
||||
},
|
||||
fr: {
|
||||
brand: 'RentalDriveGo',
|
||||
pageTitle: 'Lancez votre espace de location',
|
||||
pageSubtitle: 'Créez le compte utilisateur, complétez les informations de l’entreprise et lancez votre espace.',
|
||||
steps: ['Compte utilisateur', 'Infos entreprise', 'Plan', 'Vérifier'],
|
||||
ownerTitle: 'Compte utilisateur',
|
||||
ownerBody: 'Créez le compte utilisateur principal qui gérera cet espace entreprise.',
|
||||
partnerTitle: 'Infos entreprise',
|
||||
partnerBody: 'Complétez les informations de l’entreprise requises pour créer l’espace.',
|
||||
planTitle: 'Choisissez votre formule',
|
||||
planBody: 'Votre espace démarre immédiatement avec un essai gratuit de 14 jours dans la devise choisie.',
|
||||
reviewTitle: 'Vérifier et lancer',
|
||||
reviewBody: 'Nous allons créer immédiatement l’espace entreprise et démarrer l’essai de 14 jours avec la formule choisie.',
|
||||
firstName: 'Prénom du gérant/propriétaire',
|
||||
lastName: 'Nom du gérant/propriétaire',
|
||||
ownerEmail: 'E-mail du gérant/propriétaire',
|
||||
password: 'Mot de passe',
|
||||
confirmPassword: 'Confirmer le mot de passe',
|
||||
passwordHint: 'Utilisez au moins 8 caractères pour le compte propriétaire.',
|
||||
companyName: 'Nom de l’entreprise',
|
||||
legalForm: 'Forme juridique',
|
||||
registrationNumber: 'Registre du commerce (RC)',
|
||||
streetAddress: 'Adresse',
|
||||
city: 'Ville',
|
||||
country: 'Pays',
|
||||
zipCode: 'Code postal',
|
||||
companyPhone: 'Téléphone',
|
||||
fax: 'Fax (optionnel)',
|
||||
yearsActive: 'Années d’activité',
|
||||
continue: 'Continuer',
|
||||
back: 'Retour',
|
||||
working: 'Traitement…',
|
||||
createWorkspace: 'Créer l’espace',
|
||||
workspaceReady: 'Espace prêt',
|
||||
workspaceReadyBody: 'est maintenant actif et l’e-mail propriétaire',
|
||||
enterDashboard: 'Entrer dans le dashboard',
|
||||
signInAnotherDevice: 'Se connecter sur un autre appareil',
|
||||
reviewWorkspace: 'Espace',
|
||||
reviewPlan: 'Formule',
|
||||
reviewOwnerEmail: 'E-mail propriétaire',
|
||||
reviewPhone: 'Téléphone',
|
||||
invalidPassword: 'Choisissez un mot de passe d’au moins 8 caractères.',
|
||||
passwordMismatch: 'Les mots de passe ne correspondent pas.',
|
||||
missingRequired: 'Renseignez tous les champs obligatoires.',
|
||||
companyFallback: 'Votre entreprise',
|
||||
couldNotCreate: 'Impossible de créer l’espace',
|
||||
legalFormOptions: {
|
||||
'': 'Sélectionner la forme juridique',
|
||||
SARL: 'SARL',
|
||||
'SARL AU': 'SARL AU',
|
||||
SA: 'SA',
|
||||
SAS: 'SAS',
|
||||
AUTO_ENTREPRENEUR: 'Auto-entrepreneur',
|
||||
EI: 'Entreprise individuelle',
|
||||
OTHER: 'Autre',
|
||||
} as Record<string, string>,
|
||||
yearsActiveOptions: {
|
||||
'': 'Sélectionner les années d’activité',
|
||||
LESS_THAN_1: 'Moins de 1 an',
|
||||
ONE_TO_FIVE: '1 an à 5 ans',
|
||||
MORE_THAN_5: 'Plus > 5 ans',
|
||||
} as Record<string, string>,
|
||||
planDescriptions: {
|
||||
STARTER: 'Flotte, offres et réservations essentielles.',
|
||||
GROWTH: 'Mise en avant marketplace et plus de marge pour évoluer.',
|
||||
PRO: 'White-label complet et contrôles premium.',
|
||||
} as Record<SignupForm['plan'], string>,
|
||||
billingPeriodOptions: {
|
||||
MONTHLY: 'Mensuel',
|
||||
ANNUAL: 'Annuel',
|
||||
} as Record<SignupForm['billingPeriod'], string>,
|
||||
paymentProviderOptions: {
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<SignupForm['paymentProvider'], string>,
|
||||
},
|
||||
ar: {
|
||||
brand: 'RentalDriveGo',
|
||||
pageTitle: 'أطلق مساحة التأجير الخاصة بك',
|
||||
pageSubtitle: 'أنشئ حساب المستخدم، وأكمل معلومات الشركة، ثم أطلق مساحة العمل.',
|
||||
steps: ['حساب المستخدم', 'معلومات الشركة', 'الخطة', 'المراجعة'],
|
||||
ownerTitle: 'حساب المستخدم',
|
||||
ownerBody: 'أنشئ حساب المستخدم الرئيسي الذي سيدير مساحة عمل الشركة.',
|
||||
partnerTitle: 'معلومات الشركة',
|
||||
partnerBody: 'أكمل معلومات الشركة المطلوبة لإنشاء مساحة العمل.',
|
||||
planTitle: 'اختر خطتك',
|
||||
planBody: 'تبدأ المساحة فوراً بفترة تجريبية مجانية لمدة 14 يوماً بالعملة التي تفضلها.',
|
||||
reviewTitle: 'راجع وأطلق',
|
||||
reviewBody: 'سننشئ مساحة الشركة فوراً ونبدأ الفترة التجريبية لمدة 14 يوماً بالخطة التي اخترتها.',
|
||||
firstName: 'الاسم الأول للمدير/المالك',
|
||||
lastName: 'اسم العائلة للمدير/المالك',
|
||||
ownerEmail: 'بريد المدير/المالك الإلكتروني',
|
||||
password: 'كلمة المرور',
|
||||
confirmPassword: 'تأكيد كلمة المرور',
|
||||
passwordHint: 'استخدم 8 أحرف على الأقل لحساب مالك الشركة.',
|
||||
companyName: 'اسم الشركة',
|
||||
legalForm: 'الشكل القانوني',
|
||||
registrationNumber: 'السجل التجاري (RC)',
|
||||
streetAddress: 'عنوان الشارع',
|
||||
city: 'المدينة',
|
||||
country: 'الدولة',
|
||||
zipCode: 'الرمز البريدي',
|
||||
companyPhone: 'الهاتف',
|
||||
fax: 'الفاكس (اختياري)',
|
||||
yearsActive: 'سنوات النشاط',
|
||||
continue: 'متابعة',
|
||||
back: 'رجوع',
|
||||
working: 'جارٍ التنفيذ…',
|
||||
createWorkspace: 'إنشاء المساحة',
|
||||
workspaceReady: 'المساحة جاهزة',
|
||||
workspaceReadyBody: 'أصبحت الآن نشطة وتم تسجيل بريد المالك',
|
||||
enterDashboard: 'الدخول إلى لوحة التحكم',
|
||||
signInAnotherDevice: 'تسجيل الدخول على جهاز آخر',
|
||||
reviewWorkspace: 'المساحة',
|
||||
reviewPlan: 'الخطة',
|
||||
reviewOwnerEmail: 'بريد المالك',
|
||||
reviewPhone: 'الهاتف',
|
||||
invalidPassword: 'اختر كلمة مرور من 8 أحرف على الأقل.',
|
||||
passwordMismatch: 'كلمتا المرور غير متطابقتين.',
|
||||
missingRequired: 'أكمل جميع الحقول الإلزامية.',
|
||||
companyFallback: 'شركتك',
|
||||
couldNotCreate: 'تعذر إنشاء المساحة',
|
||||
legalFormOptions: {
|
||||
'': 'اختر الشكل القانوني',
|
||||
SARL: 'شركة ذات مسؤولية محدودة',
|
||||
'SARL AU': 'شركة ذات مسؤولية محدودة لشخص واحد',
|
||||
SA: 'شركة مساهمة',
|
||||
SAS: 'شركة مساهمة مبسطة',
|
||||
AUTO_ENTREPRENEUR: 'مقاول ذاتي',
|
||||
EI: 'مؤسسة فردية',
|
||||
OTHER: 'أخرى',
|
||||
} as Record<string, string>,
|
||||
yearsActiveOptions: {
|
||||
'': 'اختر سنوات النشاط',
|
||||
LESS_THAN_1: 'أقل من سنة',
|
||||
ONE_TO_FIVE: 'من سنة إلى 5 سنوات',
|
||||
MORE_THAN_5: 'أكثر من 5 سنوات',
|
||||
} as Record<string, string>,
|
||||
planDescriptions: {
|
||||
STARTER: 'الأساسيات للأسطول والعروض والحجوزات.',
|
||||
GROWTH: 'ظهور مميز في السوق ومساحة أكبر للنمو.',
|
||||
PRO: 'تحكم كامل وواجهة بيضاء متقدمة.',
|
||||
} as Record<SignupForm['plan'], string>,
|
||||
billingPeriodOptions: {
|
||||
MONTHLY: 'شهري',
|
||||
ANNUAL: 'سنوي',
|
||||
} as Record<SignupForm['billingPeriod'], string>,
|
||||
paymentProviderOptions: {
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<SignupForm['paymentProvider'], string>,
|
||||
},
|
||||
}[language]
|
||||
|
||||
async function submitSignup() {
|
||||
if (form.password.length < 8) {
|
||||
setError('Choose a password with at least 8 characters.')
|
||||
setError(dict.invalidPassword)
|
||||
return
|
||||
}
|
||||
|
||||
if (form.password !== form.confirmPassword) {
|
||||
setError('Passwords do not match.')
|
||||
setError(dict.passwordMismatch)
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasRequiredPartnerFields(form)) {
|
||||
setError(dict.missingRequired)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -90,9 +344,15 @@ export default function SignUpPage() {
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
companyName: form.companyName,
|
||||
companyPhone: form.companyPhone || undefined,
|
||||
city: form.city || undefined,
|
||||
country: form.country || undefined,
|
||||
legalForm: form.legalForm,
|
||||
registrationNumber: form.registrationNumber,
|
||||
streetAddress: form.streetAddress,
|
||||
city: form.city,
|
||||
country: form.country,
|
||||
zipCode: form.zipCode,
|
||||
companyPhone: form.companyPhone,
|
||||
fax: form.fax || undefined,
|
||||
yearsActive: form.yearsActive,
|
||||
plan: form.plan,
|
||||
billingPeriod: form.billingPeriod,
|
||||
currency: form.currency,
|
||||
@@ -103,10 +363,10 @@ export default function SignUpPage() {
|
||||
setCompletedSignup({
|
||||
companyName: form.companyName,
|
||||
email: form.email,
|
||||
emailWarning: getEmailWarning(response.emailDelivery),
|
||||
emailWarning: getEmailWarning(response.emailDelivery, language),
|
||||
})
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Could not create workspace'))
|
||||
setError(getErrorMessage(err, dict.couldNotCreate))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -117,11 +377,11 @@ export default function SignUpPage() {
|
||||
<PublicShell>
|
||||
<main className="flex-1 px-4 py-16">
|
||||
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
|
||||
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
|
||||
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">Workspace ready</h1>
|
||||
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</Link>
|
||||
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">{dict.workspaceReady}</h1>
|
||||
<p className="mt-4 text-base leading-7 text-slate-600">
|
||||
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> is now active and the owner email
|
||||
<span className="font-semibold text-slate-900"> {completedSignup.email}</span> has been registered.
|
||||
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> {dict.workspaceReadyBody}
|
||||
<span className="font-semibold text-slate-900"> {completedSignup.email}</span>.
|
||||
</p>
|
||||
{completedSignup.emailWarning ? (
|
||||
<div className="mt-6 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
@@ -130,10 +390,10 @@ export default function SignUpPage() {
|
||||
) : null}
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Link href="/dashboard" className="btn-primary justify-center">
|
||||
Enter dashboard
|
||||
{dict.enterDashboard}
|
||||
</Link>
|
||||
<Link href="/sign-in" className="btn-secondary justify-center">
|
||||
Sign in on another device
|
||||
{dict.signInAnotherDevice}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,15 +405,15 @@ export default function SignUpPage() {
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="px-4 py-12">
|
||||
<div className="mx-auto max-w-3xl space-y-8">
|
||||
<div className="text-center">
|
||||
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
|
||||
<h1 className="mt-3 text-5xl font-black tracking-tight text-slate-900">Launch your rental workspace</h1>
|
||||
<p className="mt-4 text-base leading-7 text-slate-600">Create the owner account and start your 14-day free trial.</p>
|
||||
<div className="mx-auto max-w-4xl space-y-8">
|
||||
<div className={`text-center ${isArabic ? 'rtl' : ''}`}>
|
||||
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</Link>
|
||||
<h1 className="mt-3 text-5xl font-black tracking-tight text-slate-900">{dict.pageTitle}</h1>
|
||||
<p className="mt-4 text-base leading-7 text-slate-600">{dict.pageSubtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
{['Account', 'Company', 'Plan', 'Verify'].map((label, index) => (
|
||||
{dict.steps.map((label, index) => (
|
||||
<div key={label} className={`rounded-full px-4 py-2 text-center text-sm font-semibold ${index + 1 <= step ? 'bg-slate-900 text-white' : 'border border-slate-200 bg-white text-slate-500'}`}>
|
||||
{label}
|
||||
</div>
|
||||
@@ -165,74 +425,89 @@ export default function SignUpPage() {
|
||||
|
||||
{step === 1 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title="Owner account" body="This becomes the primary owner account for the company workspace." />
|
||||
<SectionIntro title={dict.ownerTitle} body={dict.ownerBody} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label="First name" value={form.firstName} onChange={(value) => setForm((current) => ({ ...current, firstName: value }))} />
|
||||
<LabeledInput label="Last name" value={form.lastName} onChange={(value) => setForm((current) => ({ ...current, lastName: value }))} />
|
||||
<LabeledInput label={dict.firstName} required maxLength={80} value={form.firstName} onChange={(value) => setForm((current) => ({ ...current, firstName: normalizeTitleCase(value) }))} />
|
||||
<LabeledInput label={dict.lastName} required maxLength={80} value={form.lastName} onChange={(value) => setForm((current) => ({ ...current, lastName: normalizeTitleCase(value) }))} />
|
||||
</div>
|
||||
<LabeledInput label="Email" type="email" value={form.email} onChange={(value) => setForm((current) => ({ ...current, email: value }))} />
|
||||
<LabeledInput label={dict.ownerEmail} required type="email" maxLength={254} value={form.email} onChange={(value) => setForm((current) => ({ ...current, email: normalizeEmail(value) }))} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label="Password" type="password" value={form.password} onChange={(value) => setForm((current) => ({ ...current, password: value }))} />
|
||||
<LabeledInput label="Confirm password" type="password" value={form.confirmPassword} onChange={(value) => setForm((current) => ({ ...current, confirmPassword: value }))} />
|
||||
<LabeledInput label={dict.password} required type="password" maxLength={128} value={form.password} onChange={(value) => setForm((current) => ({ ...current, password: value }))} />
|
||||
<LabeledInput label={dict.confirmPassword} required type="password" maxLength={128} value={form.confirmPassword} onChange={(value) => setForm((current) => ({ ...current, confirmPassword: value }))} />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">Use at least 8 characters for your company owner account.</p>
|
||||
<p className="text-xs text-slate-500">{dict.passwordHint}</p>
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={() => setStep(2)} className="btn-primary">Continue</button>
|
||||
<button type="button" onClick={() => setStep(2)} className="btn-primary">{dict.continue}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 2 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title="Company details" body="We’ll use these details for your trial workspace and public marketplace profile." />
|
||||
<LabeledInput label="Company name" value={form.companyName} onChange={(value) => setForm((current) => ({ ...current, companyName: value }))} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label="Phone" value={form.companyPhone} onChange={(value) => setForm((current) => ({ ...current, companyPhone: value }))} />
|
||||
<LabeledInput label="Country" value={form.country} onChange={(value) => setForm((current) => ({ ...current, country: value }))} />
|
||||
<SectionIntro title={dict.partnerTitle} body={dict.partnerBody} />
|
||||
<div className="space-y-4">
|
||||
<LabeledInput label={dict.companyName} required maxLength={120} value={form.companyName} onChange={(value) => setForm((current) => ({ ...current, companyName: normalizeTitleCase(value) }))} />
|
||||
<LabeledSelect label={dict.legalForm} required value={form.legalForm} options={['', ...LEGAL_FORM_OPTIONS]} labels={dict.legalFormOptions} onChange={(value) => setForm((current) => ({ ...current, legalForm: value }))} />
|
||||
<LabeledInput label={dict.registrationNumber} required maxLength={120} value={form.registrationNumber} onChange={(value) => setForm((current) => ({ ...current, registrationNumber: normalizeTitleCase(value) }))} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.companyPhone} required maxLength={80} value={form.companyPhone} onChange={(value) => setForm((current) => ({ ...current, companyPhone: value }))} />
|
||||
<LabeledInput label={dict.fax} maxLength={80} value={form.fax} onChange={(value) => setForm((current) => ({ ...current, fax: value }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.streetAddress} required maxLength={200} value={form.streetAddress} onChange={(value) => setForm((current) => ({ ...current, streetAddress: normalizeUpper(value) }))} />
|
||||
<LabeledInput label={dict.city} required maxLength={120} value={form.city} onChange={(value) => setForm((current) => ({ ...current, city: normalizeUpper(value) }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.country} required maxLength={120} value={form.country} onChange={(value) => setForm((current) => ({ ...current, country: normalizeUpper(value) }))} />
|
||||
<LabeledInput label={dict.zipCode} required maxLength={40} value={form.zipCode} onChange={(value) => setForm((current) => ({ ...current, zipCode: value.toUpperCase() }))} />
|
||||
</div>
|
||||
<LabeledSelect label={dict.yearsActive} required value={form.yearsActive} options={['', ...YEARS_ACTIVE_OPTIONS]} labels={dict.yearsActiveOptions} onChange={(value) => setForm((current) => ({ ...current, yearsActive: value }))} />
|
||||
</div>
|
||||
<LabeledInput label="City" value={form.city} onChange={(value) => setForm((current) => ({ ...current, city: value }))} />
|
||||
<NavActions onBack={() => setStep(1)} onNext={() => setStep(3)} />
|
||||
<NavActions onBack={() => setStep(1)} onNext={() => setStep(3)} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 3 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title="Choose your plan" body="Your workspace starts immediately on a 14-day free trial in your preferred billing currency." />
|
||||
<SectionIntro title={dict.planTitle} body={dict.planBody} />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{['STARTER', 'GROWTH', 'PRO'].map((plan) => (
|
||||
{(['STARTER', 'GROWTH', 'PRO'] as const).map((plan) => (
|
||||
<button
|
||||
key={plan}
|
||||
type="button"
|
||||
onClick={() => setForm((current) => ({ ...current, plan: plan as SignupForm['plan'] }))}
|
||||
onClick={() => setForm((current) => ({ ...current, plan }))}
|
||||
className={`rounded-3xl border p-5 text-left ${form.plan === plan ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white text-slate-900'}`}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em]">{plan}</p>
|
||||
<p className="mt-3 text-sm opacity-80">{plan === 'STARTER' ? 'Core fleet, offers, and bookings.' : plan === 'GROWTH' ? 'Featured marketplace placement and more room to scale.' : 'Full white-label and premium controls.'}</p>
|
||||
<p className="mt-3 text-sm opacity-80">{dict.planDescriptions[plan]}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<LabeledSelect label="Billing period" value={form.billingPeriod} options={['MONTHLY', 'ANNUAL']} onChange={(value) => setForm((current) => ({ ...current, billingPeriod: value as SignupForm['billingPeriod'] }))} />
|
||||
<LabeledSelect label="Currency" value={form.currency} options={['MAD', 'USD', 'EUR']} onChange={(value) => setForm((current) => ({ ...current, currency: value as SignupForm['currency'] }))} />
|
||||
<LabeledSelect label="Primary provider" value={form.paymentProvider} options={['AMANPAY', 'PAYPAL']} onChange={(value) => setForm((current) => ({ ...current, paymentProvider: value as SignupForm['paymentProvider'] }))} />
|
||||
<LabeledSelect label={language === 'fr' ? 'Période de facturation' : language === 'ar' ? 'فترة الفوترة' : 'Billing period'} value={form.billingPeriod} options={['MONTHLY', 'ANNUAL']} labels={dict.billingPeriodOptions} onChange={(value) => setForm((current) => ({ ...current, billingPeriod: value as SignupForm['billingPeriod'] }))} />
|
||||
<LabeledSelect label={language === 'fr' ? 'Devise' : language === 'ar' ? 'العملة' : 'Currency'} value={form.currency} options={['MAD', 'USD', 'EUR']} onChange={(value) => setForm((current) => ({ ...current, currency: value as SignupForm['currency'] }))} />
|
||||
<LabeledSelect label={language === 'fr' ? 'Prestataire principal' : language === 'ar' ? 'مزود الدفع الرئيسي' : 'Primary provider'} value={form.paymentProvider} options={['AMANPAY', 'PAYPAL']} labels={dict.paymentProviderOptions} onChange={(value) => setForm((current) => ({ ...current, paymentProvider: value as SignupForm['paymentProvider'] }))} />
|
||||
</div>
|
||||
<NavActions onBack={() => setStep(2)} onNext={() => setStep(4)} />
|
||||
<NavActions onBack={() => setStep(2)} onNext={() => setStep(4)} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 4 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title="Review and launch" body="We’ll create the company workspace immediately and start the 14-day trial with the plan you selected." />
|
||||
<SectionIntro title={dict.reviewTitle} body={dict.reviewBody} />
|
||||
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
|
||||
<p><span className="font-semibold text-slate-900">Workspace:</span> {form.companyName || 'Your company'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">Plan:</span> {form.plan} · {form.billingPeriod} · {form.currency}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">Owner email:</span> {form.email || '—'}</p>
|
||||
<p><span className="font-semibold text-slate-900">{dict.reviewWorkspace}:</span> {form.companyName || dict.companyFallback}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPlan}:</span> {form.plan} · {dict.billingPeriodOptions[form.billingPeriod]} · {form.currency}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewOwnerEmail}:</span> {form.email || '—'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPhone}:</span> {form.companyPhone || '—'}</p>
|
||||
</div>
|
||||
<NavActions
|
||||
onBack={() => setStep(3)}
|
||||
onNext={submitSignup}
|
||||
loading={loading}
|
||||
nextLabel="Create workspace"
|
||||
nextLabel={dict.createWorkspace}
|
||||
backLabel={dict.back}
|
||||
loadingLabel={dict.working}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -243,6 +518,20 @@ export default function SignUpPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function hasRequiredPartnerFields(form: SignupForm) {
|
||||
return [
|
||||
form.companyName,
|
||||
form.legalForm,
|
||||
form.registrationNumber,
|
||||
form.streetAddress,
|
||||
form.city,
|
||||
form.country,
|
||||
form.zipCode,
|
||||
form.companyPhone,
|
||||
form.yearsActive,
|
||||
].every((value) => value.trim().length > 0)
|
||||
}
|
||||
|
||||
function SectionIntro({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div>
|
||||
@@ -252,12 +541,16 @@ function SectionIntro({ title, body }: { title: string; body: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function getEmailWarning(emailDelivery: SignupResponse['emailDelivery']) {
|
||||
function getEmailWarning(emailDelivery: SignupResponse['emailDelivery'], language: 'en' | 'fr' | 'ar') {
|
||||
if (!emailDelivery) return null
|
||||
if (emailDelivery.success) return null
|
||||
if (emailDelivery.attempted) {
|
||||
if (language === 'fr') return `Le compte a été créé, mais l’e-mail de confirmation n’a pas pu être envoyé${emailDelivery.error ? ` : ${emailDelivery.error}` : '.'}`
|
||||
if (language === 'ar') return `تم إنشاء الحساب، لكن تعذر إرسال رسالة التأكيد${emailDelivery.error ? `: ${emailDelivery.error}` : '.'}`
|
||||
return `The account was created, but the confirmation email could not be delivered${emailDelivery.error ? `: ${emailDelivery.error}` : '.'}`
|
||||
}
|
||||
if (language === 'fr') return 'Le compte a été créé, mais l’envoi d’e-mails n’est pas encore configuré sur cet environnement.'
|
||||
if (language === 'ar') return 'تم إنشاء الحساب، لكن إرسال البريد الإلكتروني غير مهيأ بعد في هذه البيئة.'
|
||||
return 'The account was created, but email sending is not configured on this environment yet.'
|
||||
}
|
||||
|
||||
@@ -266,38 +559,101 @@ function getErrorMessage(error: unknown, fallback: string) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
function LabeledInput({ label, value, onChange, type = 'text' }: { label: string; value: string; onChange: (value: string) => void; type?: string }) {
|
||||
function normalizeTitleCase(value: string) {
|
||||
if (!value) return value
|
||||
const trimmedStart = value.replace(/^\s+/, '')
|
||||
if (!trimmedStart) return ''
|
||||
return trimmedStart.charAt(0).toUpperCase() + trimmedStart.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeEmail(value: string) {
|
||||
return value.replace(/\s+/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeUpper(value: string) {
|
||||
return value.replace(/^\s+/, '').toUpperCase()
|
||||
}
|
||||
|
||||
function LabeledInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = 'text',
|
||||
required = false,
|
||||
maxLength,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
type?: string
|
||||
required?: boolean
|
||||
maxLength?: number
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">{label}</span>
|
||||
<input type={type} className="input-field" value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
{label}
|
||||
{required ? <span className="ml-1 text-red-600">*</span> : null}
|
||||
</span>
|
||||
<input type={type} maxLength={maxLength} className="input-field" value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function LabeledSelect({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) {
|
||||
function LabeledSelect({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
labels,
|
||||
required = false,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: readonly string[]
|
||||
onChange: (value: string) => void
|
||||
labels?: Record<string, string>
|
||||
required?: boolean
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">{label}</span>
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
{label}
|
||||
{required ? <span className="ml-1 text-red-600">*</span> : null}
|
||||
</span>
|
||||
<select className="input-field" value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
<option key={option || 'empty'} value={option}>{labels?.[option] ?? option}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function NavActions({ onBack, onNext, loading = false, nextLabel = 'Continue' }: { onBack?: () => void; onNext: () => void; loading?: boolean; nextLabel?: string }) {
|
||||
function NavActions({
|
||||
onBack,
|
||||
onNext,
|
||||
loading = false,
|
||||
nextLabel = 'Continue',
|
||||
backLabel = 'Back',
|
||||
loadingLabel = 'Working…',
|
||||
}: {
|
||||
onBack?: () => void
|
||||
onNext: () => void
|
||||
loading?: boolean
|
||||
nextLabel?: string
|
||||
backLabel?: string
|
||||
loadingLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
{onBack ? (
|
||||
<button type="button" onClick={onBack} className="btn-secondary flex-1 justify-center">
|
||||
Back
|
||||
{backLabel}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={onNext} disabled={loading} className="btn-primary flex-1 justify-center disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{loading ? 'Working…' : nextLabel}
|
||||
{loading ? loadingLabel : nextLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -316,7 +316,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
team: 'Team',
|
||||
reports: 'Reports',
|
||||
subscription: 'Subscription',
|
||||
billing: 'Rent Car Billing',
|
||||
billing: 'Customer Billing',
|
||||
contracts: 'Contracts',
|
||||
notifications: 'Notifications',
|
||||
settings: 'Settings',
|
||||
@@ -332,7 +332,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
'/dashboard/team': 'Team',
|
||||
'/dashboard/reports': 'Reports',
|
||||
'/dashboard/subscription': 'Subscription',
|
||||
'/dashboard/billing': 'Rent Car Billing',
|
||||
'/dashboard/billing': 'Customer Billing',
|
||||
'/dashboard/contracts': 'Contracts',
|
||||
'/dashboard/notifications': 'Notifications',
|
||||
'/dashboard/settings': 'Settings',
|
||||
@@ -659,7 +659,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
team: 'Équipe',
|
||||
reports: 'Rapports',
|
||||
subscription: 'Abonnement',
|
||||
billing: 'Facturation location',
|
||||
billing: 'Facturation clients',
|
||||
contracts: 'Contrats',
|
||||
notifications: 'Notifications',
|
||||
settings: 'Paramètres',
|
||||
@@ -675,7 +675,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
'/dashboard/team': 'Équipe',
|
||||
'/dashboard/reports': 'Rapports',
|
||||
'/dashboard/subscription': 'Abonnement',
|
||||
'/dashboard/billing': 'Facturation location',
|
||||
'/dashboard/billing': 'Facturation clients',
|
||||
'/dashboard/contracts': 'Contrats',
|
||||
'/dashboard/notifications': 'Notifications',
|
||||
'/dashboard/settings': 'Paramètres',
|
||||
@@ -1002,7 +1002,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
team: 'الفريق',
|
||||
reports: 'التقارير',
|
||||
subscription: 'الاشتراك',
|
||||
billing: 'فوترة تأجير السيارات',
|
||||
billing: 'فوترة العملاء',
|
||||
contracts: 'العقود',
|
||||
notifications: 'الإشعارات',
|
||||
settings: 'الإعدادات',
|
||||
@@ -1018,7 +1018,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
'/dashboard/team': 'الفريق',
|
||||
'/dashboard/reports': 'التقارير',
|
||||
'/dashboard/subscription': 'الاشتراك',
|
||||
'/dashboard/billing': 'فوترة تأجير السيارات',
|
||||
'/dashboard/billing': 'فوترة العملاء',
|
||||
'/dashboard/contracts': 'العقود',
|
||||
'/dashboard/notifications': 'الإشعارات',
|
||||
'/dashboard/settings': 'الإعدادات',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import VehicleConditionSheet from './VehicleConditionSheet'
|
||||
|
||||
type InspectionType = 'CHECKIN' | 'CHECKOUT'
|
||||
type FuelLevel = 'FULL' | 'SEVEN_EIGHTHS' | 'THREE_QUARTERS' | 'FIVE_EIGHTHS' | 'HALF' | 'THREE_EIGHTHS' | 'QUARTER' | 'ONE_EIGHTH' | 'EMPTY'
|
||||
@@ -40,19 +41,35 @@ const severityColors: Record<DamageSeverity, string> = {
|
||||
MAJOR: '#dc2626',
|
||||
}
|
||||
|
||||
function pointKey(point: Pick<DamagePoint, 'viewType' | 'x' | 'y' | 'damageType' | 'severity'>) {
|
||||
return `${point.viewType}:${point.x.toFixed(2)}:${point.y.toFixed(2)}:${point.damageType}:${point.severity}`
|
||||
}
|
||||
|
||||
export default function DamageInspectionCard({
|
||||
reservationId,
|
||||
type,
|
||||
initialInspection,
|
||||
comparisonPoints = [],
|
||||
editable = true,
|
||||
readOnlyMessage,
|
||||
onSaved,
|
||||
}: {
|
||||
reservationId: string
|
||||
type: InspectionType
|
||||
initialInspection?: DamageInspection | null
|
||||
comparisonPoints?: DamagePoint[]
|
||||
editable?: boolean
|
||||
readOnlyMessage?: string | null
|
||||
onSaved: (inspection: DamageInspection) => void
|
||||
}) {
|
||||
const { dict } = useDashboardI18n()
|
||||
const r = dict.reservations
|
||||
const storedPreExistingPoints = type === 'CHECKOUT'
|
||||
? (initialInspection?.damagePoints ?? []).filter((point) => point.isPreExisting)
|
||||
: []
|
||||
const overlayComparisonPoints = type === 'CHECKOUT'
|
||||
? [...comparisonPoints, ...storedPreExistingPoints].filter((point, index, all) => all.findIndex((item) => pointKey(item) === pointKey(point)) === index)
|
||||
: []
|
||||
|
||||
const [inspection, setInspection] = useState<DamageInspection>({
|
||||
id: initialInspection?.id ?? `${type.toLowerCase()}-draft`,
|
||||
@@ -63,7 +80,9 @@ export default function DamageInspectionCard({
|
||||
generalCondition: initialInspection?.generalCondition ?? '',
|
||||
employeeNotes: initialInspection?.employeeNotes ?? '',
|
||||
customerAgreed: initialInspection?.customerAgreed ?? false,
|
||||
damagePoints: initialInspection?.damagePoints ?? [],
|
||||
damagePoints: type === 'CHECKOUT'
|
||||
? (initialInspection?.damagePoints ?? []).filter((point) => !point.isPreExisting)
|
||||
: (initialInspection?.damagePoints ?? []),
|
||||
})
|
||||
const [damageType, setDamageType] = useState<DamageType>('SCRATCH')
|
||||
const [severity, setSeverity] = useState<DamageSeverity>('MINOR')
|
||||
@@ -74,14 +93,17 @@ export default function DamageInspectionCard({
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload = {
|
||||
...(inspection.mileage !== null ? { mileage: inspection.mileage } : {}),
|
||||
fuelLevel: inspection.fuelLevel,
|
||||
...(inspection.fuelCharge !== null ? { fuelCharge: inspection.fuelCharge } : {}),
|
||||
...(inspection.generalCondition ? { generalCondition: inspection.generalCondition } : {}),
|
||||
...(inspection.employeeNotes ? { employeeNotes: inspection.employeeNotes } : {}),
|
||||
customerAgreed: inspection.customerAgreed,
|
||||
damagePoints: inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
|
||||
const payloadPoints = [
|
||||
...overlayComparisonPoints.map(({ viewType, x, y, damageType, severity, description }) => ({
|
||||
viewType,
|
||||
x,
|
||||
y,
|
||||
damageType,
|
||||
severity,
|
||||
...(description ? { description } : {}),
|
||||
isPreExisting: true,
|
||||
})),
|
||||
...inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
|
||||
viewType,
|
||||
x,
|
||||
y,
|
||||
@@ -90,13 +112,28 @@ export default function DamageInspectionCard({
|
||||
...(description ? { description } : {}),
|
||||
isPreExisting,
|
||||
})),
|
||||
]
|
||||
|
||||
const payload = {
|
||||
...(inspection.mileage !== null ? { mileage: inspection.mileage } : {}),
|
||||
fuelLevel: inspection.fuelLevel,
|
||||
...(inspection.fuelCharge !== null ? { fuelCharge: inspection.fuelCharge } : {}),
|
||||
...(inspection.generalCondition ? { generalCondition: inspection.generalCondition } : {}),
|
||||
...(inspection.employeeNotes ? { employeeNotes: inspection.employeeNotes } : {}),
|
||||
customerAgreed: inspection.customerAgreed,
|
||||
damagePoints: payloadPoints,
|
||||
}
|
||||
|
||||
const result = await apiFetch<DamageInspection>(`/reservations/${reservationId}/inspections/${type.toLowerCase()}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
setInspection(result)
|
||||
setInspection({
|
||||
...result,
|
||||
damagePoints: type === 'CHECKOUT'
|
||||
? result.damagePoints.filter((point) => !point.isPreExisting)
|
||||
: result.damagePoints,
|
||||
})
|
||||
onSaved(result)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? r.failedSaveInspection)
|
||||
@@ -105,15 +142,12 @@ export default function DamageInspectionCard({
|
||||
}
|
||||
}
|
||||
|
||||
function addDamagePoint(event: React.MouseEvent<SVGSVGElement>) {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const x = ((event.clientX - rect.left) / rect.width) * 100
|
||||
const y = ((event.clientY - rect.top) / rect.height) * 180
|
||||
function addDamagePoint(viewType: DamagePoint['viewType'], x: number, y: number) {
|
||||
setInspection((current) => ({
|
||||
...current,
|
||||
damagePoints: [
|
||||
...current.damagePoints,
|
||||
{ viewType: 'TOP', x, y, damageType, severity, description: '', isPreExisting: type === 'CHECKIN' },
|
||||
{ viewType, x, y, damageType, severity, description: '', isPreExisting: type === 'CHECKIN' },
|
||||
],
|
||||
}))
|
||||
}
|
||||
@@ -132,12 +166,17 @@ export default function DamageInspectionCard({
|
||||
<h3 className="text-base font-semibold text-slate-900">{r.inspectionCardTitle(type)}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{r.inspectionSubtitle}</p>
|
||||
</div>
|
||||
<button onClick={saveInspection} disabled={saving} className="btn-primary">
|
||||
<button onClick={saveInspection} disabled={saving || !editable} className="btn-primary">
|
||||
{saving ? r.savingInspection : r.saveInspection}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
|
||||
{!editable && readOnlyMessage && (
|
||||
<div className="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
{readOnlyMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
@@ -147,6 +186,7 @@ export default function DamageInspectionCard({
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setDamageType(option)}
|
||||
disabled={!editable}
|
||||
className={`rounded-full px-3 py-1.5 font-semibold ${damageType === option ? 'bg-slate-900 text-white' : 'bg-white text-slate-600'}`}
|
||||
>
|
||||
{r.damageTypeLabels[option]}
|
||||
@@ -159,6 +199,7 @@ export default function DamageInspectionCard({
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setSeverity(option)}
|
||||
disabled={!editable}
|
||||
className="rounded-full px-3 py-1.5 font-semibold text-white"
|
||||
style={{ backgroundColor: severityColors[option], opacity: severity === option ? 1 : 0.55 }}
|
||||
>
|
||||
@@ -166,22 +207,26 @@ export default function DamageInspectionCard({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{type === 'CHECKOUT' && overlayComparisonPoints.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-2xl border border-slate-200 bg-slate-100 px-3 py-2 text-xs text-slate-600">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="h-3.5 w-3.5 rounded-full border-2 border-dashed border-slate-500 bg-white" />
|
||||
{r.inspectionCardTitle('CHECKIN')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="h-3.5 w-3.5 rounded-full bg-slate-900" />
|
||||
{r.inspectionCardTitle('CHECKOUT')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<svg viewBox="0 0 100 180" className="w-full cursor-crosshair rounded-2xl border border-slate-200 bg-white" onClick={addDamagePoint}>
|
||||
<rect x="30" y="12" width="40" height="156" rx="18" fill="#e2e8f0" stroke="#94a3b8" />
|
||||
<rect x="36" y="22" width="28" height="24" rx="8" fill="#cbd5e1" />
|
||||
<rect x="36" y="54" width="28" height="70" rx="8" fill="#f8fafc" stroke="#cbd5e1" />
|
||||
<rect x="36" y="132" width="28" height="20" rx="8" fill="#cbd5e1" />
|
||||
<rect x="18" y="38" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
<rect x="70" y="38" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
<rect x="18" y="114" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
<rect x="70" y="114" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
{inspection.damagePoints.map((point, index) => (
|
||||
<g key={`${point.x}-${point.y}-${index}`}>
|
||||
<circle cx={point.x} cy={point.y} r="3.8" fill={severityColors[point.severity]} stroke="white" strokeWidth="1.5" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
<VehicleConditionSheet
|
||||
points={inspection.damagePoints}
|
||||
comparisonPoints={overlayComparisonPoints}
|
||||
interactive={editable}
|
||||
onAddPoint={editable ? addDamagePoint : undefined}
|
||||
className={`w-full rounded-2xl border border-slate-200 bg-white ${editable ? 'cursor-crosshair' : 'cursor-default'}`}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-slate-500">{r.diagramHelp}</p>
|
||||
</div>
|
||||
|
||||
@@ -193,12 +238,13 @@ export default function DamageInspectionCard({
|
||||
type="number"
|
||||
className="input-field"
|
||||
value={inspection.mileage ?? ''}
|
||||
disabled={!editable}
|
||||
onChange={(e) => setInspection((cur) => ({ ...cur, mileage: e.target.value ? Number(e.target.value) : null }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.fuelLevelLabel}</label>
|
||||
<select className="input-field" value={inspection.fuelLevel} onChange={(e) => setInspection((cur) => ({ ...cur, fuelLevel: e.target.value as FuelLevel }))}>
|
||||
<select className="input-field" value={inspection.fuelLevel} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, fuelLevel: e.target.value as FuelLevel }))}>
|
||||
{fuelLevels.map((fl) => <option key={fl} value={fl}>{r.fuelLevels[fl]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -206,16 +252,16 @@ export default function DamageInspectionCard({
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.generalConditionLabel}</label>
|
||||
<textarea className="input-field min-h-24" value={inspection.generalCondition ?? ''} onChange={(e) => setInspection((cur) => ({ ...cur, generalCondition: e.target.value }))} />
|
||||
<textarea className="input-field min-h-24" value={inspection.generalCondition ?? ''} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, generalCondition: e.target.value }))} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.employeeNotesLabel}</label>
|
||||
<textarea className="input-field min-h-24" value={inspection.employeeNotes ?? ''} onChange={(e) => setInspection((cur) => ({ ...cur, employeeNotes: e.target.value }))} />
|
||||
<textarea className="input-field min-h-24" value={inspection.employeeNotes ?? ''} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, employeeNotes: e.target.value }))} />
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" checked={inspection.customerAgreed} onChange={(e) => setInspection((cur) => ({ ...cur, customerAgreed: e.target.checked }))} />
|
||||
<input type="checkbox" checked={inspection.customerAgreed} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, customerAgreed: e.target.checked }))} />
|
||||
{r.customerAcknowledged}
|
||||
</label>
|
||||
|
||||
@@ -228,9 +274,9 @@ export default function DamageInspectionCard({
|
||||
<div key={`${point.x}-${point.y}-${index}`} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{r.damageTypeLabels[point.damageType]} · {r.severityLabels[point.severity]}</p>
|
||||
<p className="text-xs text-slate-500">x {point.x.toFixed(1)} · y {point.y.toFixed(1)}</p>
|
||||
<p className="text-xs text-slate-500">{point.viewType} · x {point.x.toFixed(1)} · y {point.y.toFixed(1)}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => removePoint(index)} className="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
<button type="button" onClick={() => removePoint(index)} disabled={!editable} className="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{r.removeMarker}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
export type VehicleSheetView = 'TOP' | 'FRONT' | 'REAR' | 'LEFT' | 'RIGHT'
|
||||
|
||||
export type VehicleSheetPoint = {
|
||||
viewType?: VehicleSheetView
|
||||
x: number
|
||||
y: number
|
||||
severity: 'MINOR' | 'MODERATE' | 'MAJOR'
|
||||
}
|
||||
|
||||
const SHEET_WIDTH = 573
|
||||
const SHEET_HEIGHT = 876
|
||||
const SHEET_IMAGE_PATH = '/vehicle-condition-template.png'
|
||||
|
||||
const severityColors: Record<VehicleSheetPoint['severity'], string> = {
|
||||
MINOR: '#f59e0b',
|
||||
MODERATE: '#f97316',
|
||||
MAJOR: '#dc2626',
|
||||
}
|
||||
|
||||
const viewBoxes: Record<VehicleSheetView, { x: number; y: number; w: number; h: number }> = {
|
||||
FRONT: { x: 161, y: 4, w: 251, h: 108 },
|
||||
TOP: { x: 150, y: 112, w: 273, h: 613 },
|
||||
REAR: { x: 156, y: 724, w: 252, h: 123 },
|
||||
LEFT: { x: 4, y: 114, w: 146, h: 618 },
|
||||
RIGHT: { x: 423, y: 114, w: 146, h: 618 },
|
||||
}
|
||||
|
||||
function resolvePointPosition(point: VehicleSheetPoint) {
|
||||
const viewType = point.viewType ?? 'TOP'
|
||||
const box = viewBoxes[viewType]
|
||||
|
||||
return {
|
||||
cx: box.x + (point.x / 100) * box.w,
|
||||
cy: box.y + (point.y / 100) * box.h,
|
||||
}
|
||||
}
|
||||
|
||||
function renderMarker(point: VehicleSheetPoint, index: number, variant: 'current' | 'comparison') {
|
||||
const { cx, cy } = resolvePointPosition(point)
|
||||
const color = severityColors[point.severity]
|
||||
|
||||
if (variant === 'comparison') {
|
||||
return (
|
||||
<g key={`comparison-${point.viewType ?? 'TOP'}-${point.x}-${point.y}-${index}`}>
|
||||
<circle cx={cx} cy={cy} r="9" fill="rgba(255,255,255,0.92)" stroke={color} strokeWidth="2.5" strokeDasharray="5 4" />
|
||||
<circle cx={cx} cy={cy} r="4" fill={color} opacity="0.28" />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={`current-${point.viewType ?? 'TOP'}-${point.x}-${point.y}-${index}`}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r="7.5"
|
||||
fill={color}
|
||||
stroke="white"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function VehicleConditionSheet({
|
||||
points,
|
||||
comparisonPoints = [],
|
||||
interactive = false,
|
||||
className = '',
|
||||
onAddPoint,
|
||||
}: {
|
||||
points: VehicleSheetPoint[]
|
||||
comparisonPoints?: VehicleSheetPoint[]
|
||||
interactive?: boolean
|
||||
className?: string
|
||||
onAddPoint?: (viewType: VehicleSheetView, x: number, y: number) => void
|
||||
}) {
|
||||
function handleClick(event: React.MouseEvent<SVGSVGElement>) {
|
||||
if (!interactive || !onAddPoint) return
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const x = ((event.clientX - rect.left) / rect.width) * SHEET_WIDTH
|
||||
const y = ((event.clientY - rect.top) / rect.height) * SHEET_HEIGHT
|
||||
|
||||
const target = (Object.entries(viewBoxes) as Array<[VehicleSheetView, { x: number; y: number; w: number; h: number }]>)
|
||||
.find(([, box]) => x >= box.x && x <= box.x + box.w && y >= box.y && y <= box.y + box.h)
|
||||
|
||||
if (!target) return
|
||||
|
||||
const [viewType, box] = target
|
||||
const localX = ((x - box.x) / box.w) * 100
|
||||
const localY = ((y - box.y) / box.h) * 100
|
||||
|
||||
onAddPoint(viewType, localX, localY)
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${SHEET_WIDTH} ${SHEET_HEIGHT}`}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<rect x="1.5" y="1.5" width={SHEET_WIDTH - 3} height={SHEET_HEIGHT - 3} rx="18" fill="#fff" stroke="#cbd5e1" strokeWidth="3" />
|
||||
<image href={SHEET_IMAGE_PATH} x="0" y="0" width={SHEET_WIDTH} height={SHEET_HEIGHT} preserveAspectRatio="xMidYMid meet" />
|
||||
{comparisonPoints.map((point, index) => renderMarker(point, index, 'comparison'))}
|
||||
{points.map((point, index) => renderMarker(point, index, 'current'))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user