diff --git a/apps/api/src/routes/auth.company.ts b/apps/api/src/routes/auth.company.ts index 46ea4b5..2890441 100644 --- a/apps/api/src/routes/auth.company.ts +++ b/apps/api/src/routes/auth.company.ts @@ -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, diff --git a/apps/api/src/routes/payments.ts b/apps/api/src/routes/payments.ts index e83803a..16fedf7 100644 --- a/apps/api/src/routes/payments.ts +++ b/apps/api/src/routes/payments.ts @@ -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 diff --git a/apps/api/src/routes/reservations.ts b/apps/api/src/routes/reservations.ts index 08e91ee..d08ef70 100644 --- a/apps/api/src/routes/reservations.ts +++ b/apps/api/src/routes/reservations.ts @@ -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 + return { ...(extras as Record) } +} + +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(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 } }, diff --git a/apps/dashboard/public/vehicle-condition-template.png b/apps/dashboard/public/vehicle-condition-template.png new file mode 100644 index 0000000..acc4ce7 Binary files /dev/null and b/apps/dashboard/public/vehicle-condition-template.png differ diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx index f6c8721..3dc1b5e 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx @@ -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 = { +type BillingRow = ReservationRow & { + balanceDue: number + paymentCount: number + latestPayment: PaymentRow | null +} + +type ManualPaymentMethod = 'CASH' | 'CHECK' | 'BANK_TRANSFER' | 'CARD' | 'PAYPAL' + +const PAYMENT_STATUS_BADGE: Record = { + 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 = { 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([]) const [payments, setPayments] = useState([]) - const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const [paymentModalRow, setPaymentModalRow] = useState(null) + const [paymentMethod, setPaymentMethod] = useState('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(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, + paymentEventLabels: { + PENDING: 'Pending', + SUCCEEDED: 'Succeeded', + FAILED: 'Failed', + REFUNDED: 'Refunded', + PARTIALLY_REFUNDED: 'Partially refunded', + } as Record, + paymentProviderLabels: { + AMANPAY: 'AmanPay', + PAYPAL: 'PayPal', + } as Record, + paymentMethodLabels: { + CASH: 'Cash', + CHECK: 'Check', + BANK_TRANSFER: 'Bank transfer', + CARD: 'Card', + PAYPAL: 'PayPal', + STRIPE: 'Card', + CREDIT_CARD: 'Card', + DEBIT_CARD: 'Card', + } as Record, }, 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, + paymentEventLabels: { + PENDING: 'En attente', + SUCCEEDED: 'Réussi', + FAILED: 'Échoué', + REFUNDED: 'Remboursé', + PARTIALLY_REFUNDED: 'Partiellement remboursé', + } as Record, + paymentProviderLabels: { + AMANPAY: 'AmanPay', + PAYPAL: 'PayPal', + } as Record, + 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, }, 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, + paymentEventLabels: { + PENDING: 'قيد الانتظار', + SUCCEEDED: 'ناجح', + FAILED: 'فشل', + REFUNDED: 'مسترد', + PARTIALLY_REFUNDED: 'مسترد جزئياً', + } as Record, + paymentProviderLabels: { + AMANPAY: 'AmanPay', + PAYPAL: 'PayPal', + } as Record, + paymentMethodLabels: { + CASH: 'نقداً', + CHECK: 'شيك', + BANK_TRANSFER: 'تحويل بنكي', + CARD: 'بطاقة', + PAYPAL: 'PayPal', + STRIPE: 'بطاقة', + CREDIT_CARD: 'بطاقة', + DEBIT_CARD: 'بطاقة', + } as Record, }, }[language] + async function loadBillingData() { + const [reservationRows, paymentRows] = await Promise.all([ + apiFetch('/reservations?pageSize=100'), + apiFetch('/payments/company'), + ]) + setReservations(reservationRows ?? []) + setPayments(paymentRows ?? []) + } + useEffect(() => { - apiFetch('/payments/company') - .then((rows) => setPayments(rows ?? [])) + loadBillingData() .catch((err) => setError(err.message ?? copy.failed)) .finally(() => setLoading(false)) - }, []) + }, [copy.failed]) + + const billingRows = useMemo(() => { + const paymentsByReservation = new Map() + + 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) { + 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(`/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 (
-
+
-

{copy.title}

+

{copy.heading}

{copy.subtitle}

- - {copy.bookCar} - + setSearch(event.target.value)} + placeholder={copy.search} + className="input-field w-full lg:max-w-md" + />
- {error ?
{error}
: null} +
+ + + + +
-
- - - - - - - - - - - - - - - - {loading ? ( - - ) : payments.length === 0 ? ( - - ) : payments.map((row) => ( - - - - - - - - - - + {error ? ( +
{error}
+ ) : ( +
+
{copy.reservation}{copy.customer}{copy.vehicle}{copy.type}{copy.provider}{copy.status}{copy.created}{copy.paidAt}{copy.amount}
{copy.loading}
{copy.empty}
#{row.reservation.id.slice(0, 8)}{row.reservation.customer.firstName} {row.reservation.customer.lastName}{row.reservation.vehicle.make} {row.reservation.vehicle.model} · {row.reservation.vehicle.licensePlate}{row.type === 'DEPOSIT' ? copy.deposit : copy.charge}{row.paymentProvider} - - {row.status} - - {new Date(row.createdAt).toLocaleDateString()}{row.paidAt ? new Date(row.paidAt).toLocaleDateString() : '—'}{formatCurrency(row.amount, row.currency)}
+ + + + + + + + + + + + + - ))} - -
{copy.customer}{copy.vehicle}{copy.invoice}{copy.rentalPeriod}{copy.total}{copy.paid}{copy.balance}{copy.status}{copy.payment}{copy.paymentMethod}{copy.actions}
-
+ + + {loading ? ( + + {copy.loading} + + ) : filteredRows.length === 0 ? ( + + {copy.noRows} + + ) : filteredRows.map((row) => ( + + +

{row.customer.firstName} {row.customer.lastName}

+

{row.customer.email}

+ + +

{row.vehicle.make} {row.vehicle.model}

+

{row.vehicle.licensePlate}

+ + +

{row.invoiceNumber ?? copy.unknownInvoice}

+

{copy.contract}: {row.contractNumber ?? copy.none}

+ + +

{formatDate(row.startDate)}

+

{formatDate(row.endDate)}

+ + + {formatCurrency(row.totalAmount, 'MAD')} + {row.depositAmount > 0 ? ( +

{copy.deposit}: {formatCurrency(row.depositAmount, 'MAD')}

+ ) : null} + + {formatCurrency(row.paidAmount, 'MAD')} + {formatCurrency(row.balanceDue, 'MAD')} + + + {translateStatus(row.paymentStatus, copy.paymentStatusLabels)} + + + + {row.latestPayment ? ( +
+
+ + {translateStatus(row.latestPayment.status, copy.paymentEventLabels)} + + {copy.paymentProviderLabels[row.latestPayment.paymentProvider] ?? row.latestPayment.paymentProvider} +
+

+ {row.latestPayment.type === 'DEPOSIT' ? copy.depositType : copy.charge} · {formatCurrency(row.latestPayment.amount, row.latestPayment.currency)} +

+

{formatDate(row.latestPayment.paidAt ?? row.latestPayment.createdAt)} · {copy.paymentsRecorded(row.paymentCount)}

+
+ ) : ( + {copy.none} + )} + + + {row.latestPayment ? ( +
+

{translatePaymentMethod(row.latestPayment.paymentMethod)}

+

{copy.paymentProviderLabels[row.latestPayment.paymentProvider] ?? row.latestPayment.paymentProvider}

+
+ ) : ( + {copy.none} + )} + + +
+ {canAcceptPayment(row) ? ( + + ) : ( + {copy.paymentActionDisabled} + )} + + {copy.openContract} + + + {copy.openBooking} + +
+ + + ))} + + +
+ )}
+ + {paymentModalRow ? ( +
{ + if (event.target === event.currentTarget && !submittingPayment) setPaymentModalRow(null) + }}> +
+
+
+

{copy.paymentModalTitle}

+

{copy.paymentModalSubtitle}

+
+ +
+ +
+

{paymentModalRow.customer.firstName} {paymentModalRow.customer.lastName}

+

{paymentModalRow.vehicle.make} {paymentModalRow.vehicle.model} · {paymentModalRow.vehicle.licensePlate}

+

{copy.invoice}: {paymentModalRow.invoiceNumber ?? copy.unknownInvoice}

+

{copy.balance}: {formatCurrency(paymentModalRow.balanceDue, paymentCurrency)}

+
+ + {paymentError ? ( +
+ {paymentError} +
+ ) : null} + +
+
+ + +
+ +
+ + +

{paymentType === 'DEPOSIT' ? copy.depositHelp : copy.chargeHelp}

+
+ +
+ + +
+ +
+ + setPaymentAmount(event.target.value)} disabled={submittingPayment} /> +
+
+ +
+ + +
+
+
+ ) : null} +
+ ) +} + +function MetricCard({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

) } diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/contracts/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/contracts/[id]/page.tsx index 5cdb198..272dc08 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/contracts/[id]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/contracts/[id]/page.tsx @@ -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 = { - MINOR: '#f59e0b', - MODERATE: '#f97316', - MAJOR: '#dc2626', -} - -function VehicleDamageDiagram({ points }: { points: DamagePoint[] }) { - return ( - - - - - - - - - - {points.map((point, index) => ( - - ))} - - ) -} - function formatAddress(address: string | Record | 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 = { + 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(null) const [error, setError] = useState(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(`/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, 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}

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

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

-

{copy.status}

+

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

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

-

{copy.paymentStatus}

+

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

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

-

{copy.contractNo}

+

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

{contract.contractNumber ?? '—'}

-

{copy.generated}

+

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

{formatDateTime(contract.generatedAt)}

@@ -779,61 +911,64 @@ export default function ContractDetailPage() {
- +
- - - - - - - - - + + + + + + + + +
- +
- - - - - - - + + + + + + +
- +
0 ? contract.insurance.policies.map((policy) => `${policy.name} (${tl(copy.chargeTypeLabels, policy.chargeType)})`).join(', ') : copy.noInsurance} className="bg-white" /> - - + +
- +

{copy.signatureAcknowledgement}

+ {isBilingual && ( +

{arCopy.signatureAcknowledgement}

+ )}

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

- {copy.customerSignature} + {bl(copy.customerSignature, arCopy.customerSignature)}
- {copy.companyRepresentative} + {bl(copy.companyRepresentative, arCopy.companyRepresentative)}
@@ -841,43 +976,37 @@ export default function ContractDetailPage() {
- +
- - - - - -
-

{copy.damageDiagram}

-
- + + + + + +
+

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

+
+
-

+

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

- -
- - - - sum + item.total, 0), contract.invoice.currency)} className="bg-white" /> - - - 0 ? contract.invoice.taxes.map((tax) => `${tax.label} ${tax.rate}%`).join(', ') : copy.none} className="bg-white" /> - - - - -
-
- - +
+
+ + + + + +
+
+

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

+
{contract.invoice.lineItems.map((item, index) => (
@@ -887,9 +1016,39 @@ export default function ContractDetailPage() {

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

))} +
+
+ {bl(copy.subtotal, arCopy.subtotal)} + {formatCurrency(contract.invoice.subtotal, contract.invoice.currency)} +
+
+ {bl(copy.taxesLabel, arCopy.taxesLabel)} + + {contract.invoice.taxes.length > 0 + ? formatCurrency(contract.invoice.taxTotal, contract.invoice.currency) + : copy.none} + +
+
+ {bl(copy.totalChargeLabel, arCopy.totalChargeLabel)} + {formatCurrency(contract.invoice.total, contract.invoice.currency)} +
+
+ {bl(copy.amountPaidLabel, arCopy.amountPaidLabel)} + {formatCurrency(contract.invoice.amountPaid, contract.invoice.currency)} +
+
+ {bl(copy.balanceDueLabel, arCopy.balanceDueLabel)} + {formatCurrency(contract.invoice.balanceDue, contract.invoice.currency)} +
+
+ {bl(copy.paymentMode, arCopy.paymentMode)} + {contract.paymentMode ?? latestPayment?.paymentMethod ?? copy.none} +
+
{latestPayment ? (
- {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)}
) : null}
@@ -898,55 +1057,49 @@ export default function ContractDetailPage() {
- {copy.roadsideAssistance} {roadsidePhone} + {bl(copy.roadsideAssistance, arCopy.roadsideAssistance)} {roadsidePhone}
-

{copy.termsAndConditions}

+

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

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

-
- {agreementClauses.map((clause, index) => ( -
-

- {index + 1}. {copy.clauseHeadings[index] ?? copy.clauseHeadings[copy.clauseHeadings.length - 1]} -

-

{clause}

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

{column.heading}

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

{clause.title}

+

{clause.body}

+
+ ))} +
+ ))} +
+ ) : ( +
+ {singleLanguageClauseColumns.map((columnClauses, columnIndex) => ( +
+ {columnClauses.map((clause, index) => ( +
+

{clause.title}

+

{clause.body}

+
+ ))} +
+ ))} +
+ )} -
- -
- - - 0 ? damagePoints.map((point) => `${tl(copy.damageTypeLabels, point.damageType)} (${tl(copy.severityLabels, point.severity)})`).join(', ') : copy.noDamage} className="bg-white" /> -
-
- - -
-
-

{copy.notes}

-

{contract.notes || copy.none}

-
-
-

{copy.footer}

-

{contract.terms.footerNote || copy.none}

-
-
-

{copy.payments}

-

{contract.invoice.payments.length > 0 ? copy.paymentsRecorded(contract.invoice.payments.length) : copy.noPayments}

-
-
-
-
diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/reservations/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/reservations/[id]/page.tsx index 7060940..16e5142 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/reservations/[id]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/reservations/[id]/page.tsx @@ -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(null) const [inspections, setInspections] = useState([]) + const [form, setForm] = useState(null) + const [editMode, setEditMode] = useState(null) const [error, setError] = useState(null) const [actionError, setActionError] = useState(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) { + 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
{error}
- if (!reservation) return
{r.loadingReservation}
+ if (!reservation || !form) return
{r.loadingReservation}
- 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 (
@@ -126,11 +360,48 @@ export default function ReservationDetailPage() {

Reservation {reservation.id.slice(-8).toUpperCase()}

{reservation.source} · {reservation.status} · {reservation.paymentStatus}

+ {reservation.workflow.closedAt && ( +

+ {copy.closedMeta} {formatDate(reservation.workflow.closedAt)}{reservation.workflow.closedBy ? ` · ${reservation.workflow.closedBy}` : ''} +

+ )}
- - {language === 'fr' ? 'Contrat' : language === 'ar' ? 'العقد' : 'Contract'} - + {editMode === null && reservation.workflow.coreEditable && ( + + )} + {editMode === 'booking' && ( + <> + + + + )} + {editMode === null && reservation.workflow.returnEditable && ( + <> + + + + )} + {editMode === 'return' && ( + <> + + + + )} {reservation.status === 'DRAFT' && (
+ {workflowMessage && ( +
+ {workflowMessage} +
+ )} + {actionError &&
{actionError}
}
@@ -172,12 +449,129 @@ export default function ReservationDetailPage() {

{reservation.vehicle.make} {reservation.vehicle.model}

{reservation.vehicle.licensePlate}

{formatDate(reservation.startDate)} - {formatDate(reservation.endDate)}

+

{copy.paymentModeLabel}: {reservation.paymentMode || copy.paymentModeEmpty}

+
+

{copy.bookingDetails}

+
+
+ + setForm((current) => current ? { ...current, startDate: e.target.value } : current)} + /> +
+
+ + setForm((current) => current ? { ...current, endDate: e.target.value } : current)} + /> +
+
+ + setForm((current) => current ? { ...current, pickupLocation: e.target.value } : current)} + /> +
+
+ + setForm((current) => current ? { ...current, returnLocation: e.target.value } : current)} + /> +
+
+ + setForm((current) => current ? { ...current, depositAmount: e.target.value } : current)} + /> +
+
+ + setForm((current) => current ? { ...current, paymentMode: e.target.value } : current)} + /> +
+
+
+ +