fix the contract view and edit

This commit is contained in:
root
2026-05-15 11:34:29 -04:00
committed by Administrator
parent f317fa12dd
commit 593ff202a3
13 changed files with 2331 additions and 381 deletions
@@ -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 nest 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 lencaissement 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 customers negligence is at the customers 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 renters 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 sengage à ne laisser conduire la voiture quaux personnes désignées dans le contrat.\n\nIl est interdit :\n- dutiliser le véhicule à des fins illicites ;\n- de transporter des marchandises interdites ;\n- deffectuer du remorquage ;\n- de transporter des personnes contre rémunération ;\n- dutiliser 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 dentretien ou de réparation du véhicule doit être autorisée par lagence 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 dun accident, il devra payer :\n- la franchise fixée par la compagnie dassurance ;\n- les frais dimmobilisation du véhicule pendant la période de réparation.\n\nLe locataire doit informer immédiatement lagence en cas daccident.\n\n2. Assurance normale\nLe locataire est lunique responsable en cas daccident et doit payer :\n- tous les frais de réparation ;\n- les jours dimmobilisation du véhicule.',
},
{
title: 'Article 5 : Prolongation de location',
body: 'Toute prolongation doit être autorisée par lagence 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 lagence un jour à lavance.\n\nLe véhicule doit être rendu à lheure prévue. Tout retard dépassant deux heures entraînera la facturation dune 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 lemprise :\n- de lalcool ;\n- de drogues ;\n- de médicaments interdits pendant la conduite.\n\nLe locataire ne doit pas abandonner le véhicule, quelle quen 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 dune 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 jusquau retour du véhicule.',
activeLockMessage: 'La location est en cours. Les détails de la réservation sont en lecture seule jusquau retour du véhicule.',
returnLockMessage: 'Le véhicule est de retour. Seule la section de retour et linspection de retour peuvent encore être modifiées avant la clôture.',
closedLockMessage: 'La réservation est clôturée. Aucune autre modification nest 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: 'Linspection de départ est modifiable après confirmation et avant le retour.',
checkOutReadOnly: 'Linspection de retour devient modifiable une fois le véhicule retourné.',
inspectionClosed: 'Cette réservation est clôturée. Les modifications dinspection 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)} />