redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s

This commit is contained in:
root
2026-06-26 16:27:21 -04:00
parent 256ff0814e
commit d7fb7b7a7b
1030 changed files with 107374 additions and 2657 deletions
@@ -0,0 +1,644 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { formatCurrency, SupportedCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
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: SupportedCurrency
status: string
type: 'CHARGE' | 'DEPOSIT'
paymentProvider: 'AMANPAY' | 'PAYPAL'
paymentMethod: string | null
paidAt: string | null
createdAt: 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-orange-100 text-orange-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-orange-100 text-orange-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 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 [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 = 'MAD'
const [paymentAmount, setPaymentAmount] = useState('')
const [submittingPayment, setSubmittingPayment] = useState(false)
const [paymentError, setPaymentError] = useState<string | null>(null)
const copy = {
en: {
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',
invoice: 'Invoice',
rentalPeriod: 'Rental period',
total: 'Total',
paid: 'Paid',
balance: 'Balance',
status: 'Status',
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: {
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',
invoice: 'Facture',
rentalPeriod: 'Période',
total: 'Total',
paid: 'Payé',
balance: 'Solde',
status: 'Statut',
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: 'Le paiement enregistre un règlement sur le solde de la réservation.',
depositHelp: 'Le 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: {
heading: 'فوترة العملاء',
subtitle: 'إدارة فواتير العملاء وحالة الدفع والمبالغ المحصلة والأرصدة المتبقية.',
search: 'ابحث بالعميل أو الفاتورة أو العقد أو السيارة أو اللوحة…',
totalBilled: 'إجمالي الفواتير',
totalCollected: 'المحصّل',
outstanding: 'المتبقي',
openInvoices: 'الفواتير المفتوحة',
customer: 'العميل',
vehicle: 'المركبة',
invoice: 'الفاتورة',
rentalPeriod: 'فترة الإيجار',
total: 'الإجمالي',
paid: 'المدفوع',
balance: 'الرصيد',
status: 'الحالة',
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: 'دفعة',
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(() => {
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')
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="rdg-page-heading 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.heading}</h2>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={copy.search}
className="input-field w-full lg:max-w-md"
/>
</div>
<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">
{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>
</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-orange-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={`/contracts/${row.id}`} className="font-semibold text-blue-700 hover:underline">
{copy.openContract}
</Link>
<Link href={`/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-blue-950/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.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>
)
}
@@ -0,0 +1,567 @@
'use client'
import { useEffect, useState } from 'react'
import { AlertTriangle, Plus, ChevronDown, ChevronUp } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
// ─── i18n ─────────────────────────────────────────────────────────────────────
const copy = {
en: {
heading: 'Complaints',
subtitle: 'Manage customer complaints and escalations',
newComplaint: 'New complaint',
filterStatus: 'Status',
filterSeverity: 'Severity',
allStatuses: 'All statuses',
allSeverities: 'All severities',
statusOpen: 'Open',
statusInvestigating: 'Investigating',
statusResolved: 'Resolved',
statusClosed: 'Closed',
sevL1: 'Level 1',
sevL2: 'Level 2',
sevL3: 'Level 3',
noComplaints: 'No complaints found',
category: 'Category',
subject: 'Subject',
reservation: 'Reservation',
date: 'Date',
status: 'Status',
severity: 'Severity',
details: 'Details',
description: 'Description',
notes: 'Notes',
resolution: 'Resolution',
updateStatus: 'Update status',
saving: 'Saving…',
save: 'Save',
cancel: 'Cancel',
createTitle: 'Create complaint',
fieldReservationId: 'Reservation ID (optional)',
fieldCategory: 'Category',
fieldSeverity: 'Severity',
fieldSubject: 'Subject',
fieldDescription: 'Description (optional)',
create: 'Create',
creating: 'Creating…',
errorLoad: 'Failed to load complaints',
errorCreate: 'Failed to create complaint',
errorUpdate: 'Failed to update complaint',
categories: {
BOOKING: 'Booking', PICKUP: 'Pickup', VEHICLE_CLEANLINESS: 'Vehicle Cleanliness',
VEHICLE_CONDITION: 'Vehicle Condition', STAFF_SERVICE: 'Staff Service', PRICING: 'Pricing',
DEPOSIT: 'Deposit', INSURANCE: 'Insurance', DAMAGE_CLAIM: 'Damage Claim',
RETURN_PROCESS: 'Return Process', COMMUNICATION: 'Communication',
ROADSIDE_ASSISTANCE: 'Roadside Assistance', BILLING: 'Billing', OTHER: 'Other',
} as Record<string, string>,
},
fr: {
heading: 'Plaintes',
subtitle: 'Gérez les plaintes et escalades clients',
newComplaint: 'Nouvelle plainte',
filterStatus: 'Statut',
filterSeverity: 'Sévérité',
allStatuses: 'Tous les statuts',
allSeverities: 'Toutes les sévérités',
statusOpen: 'Ouvert',
statusInvestigating: 'En cours',
statusResolved: 'Résolu',
statusClosed: 'Fermé',
sevL1: 'Niveau 1',
sevL2: 'Niveau 2',
sevL3: 'Niveau 3',
noComplaints: 'Aucune plainte trouvée',
category: 'Catégorie',
subject: 'Sujet',
reservation: 'Réservation',
date: 'Date',
status: 'Statut',
severity: 'Sévérité',
details: 'Détails',
description: 'Description',
notes: 'Notes',
resolution: 'Résolution',
updateStatus: 'Mettre à jour le statut',
saving: 'Enregistrement…',
save: 'Enregistrer',
cancel: 'Annuler',
createTitle: 'Créer une plainte',
fieldReservationId: 'ID de réservation (optionnel)',
fieldCategory: 'Catégorie',
fieldSeverity: 'Sévérité',
fieldSubject: 'Sujet',
fieldDescription: 'Description (optionnel)',
create: 'Créer',
creating: 'Création…',
errorLoad: 'Échec du chargement des plaintes',
errorCreate: 'Échec de la création de la plainte',
errorUpdate: 'Échec de la mise à jour de la plainte',
categories: {
BOOKING: 'Réservation', PICKUP: 'Prise en charge', VEHICLE_CLEANLINESS: 'Propreté du véhicule',
VEHICLE_CONDITION: 'État du véhicule', STAFF_SERVICE: 'Service du personnel', PRICING: 'Tarification',
DEPOSIT: 'Dépôt de garantie', INSURANCE: 'Assurance', DAMAGE_CLAIM: 'Réclamation dommages',
RETURN_PROCESS: 'Processus de retour', COMMUNICATION: 'Communication',
ROADSIDE_ASSISTANCE: 'Assistance routière', BILLING: 'Facturation', OTHER: 'Autre',
} as Record<string, string>,
},
ar: {
heading: 'الشكاوى',
subtitle: 'إدارة شكاوى العملاء والتصعيدات',
newComplaint: 'شكوى جديدة',
filterStatus: 'الحالة',
filterSeverity: 'الخطورة',
allStatuses: 'جميع الحالات',
allSeverities: 'جميع مستويات الخطورة',
statusOpen: 'مفتوح',
statusInvestigating: 'قيد التحقيق',
statusResolved: 'تم الحل',
statusClosed: 'مغلق',
sevL1: 'المستوى 1',
sevL2: 'المستوى 2',
sevL3: 'المستوى 3',
noComplaints: 'لا توجد شكاوى',
category: 'الفئة',
subject: 'الموضوع',
reservation: 'الحجز',
date: 'التاريخ',
status: 'الحالة',
severity: 'الخطورة',
details: 'التفاصيل',
description: 'الوصف',
notes: 'ملاحظات',
resolution: 'الحل',
updateStatus: 'تحديث الحالة',
saving: 'جار الحفظ…',
save: 'حفظ',
cancel: 'إلغاء',
createTitle: 'إنشاء شكوى',
fieldReservationId: 'رقم الحجز (اختياري)',
fieldCategory: 'الفئة',
fieldSeverity: 'الخطورة',
fieldSubject: 'الموضوع',
fieldDescription: 'الوصف (اختياري)',
create: 'إنشاء',
creating: 'جار الإنشاء…',
errorLoad: 'فشل تحميل الشكاوى',
errorCreate: 'فشل إنشاء الشكوى',
errorUpdate: 'فشل تحديث الشكوى',
categories: {
BOOKING: 'الحجز', PICKUP: 'الاستلام', VEHICLE_CLEANLINESS: 'نظافة المركبة',
VEHICLE_CONDITION: 'حالة المركبة', STAFF_SERVICE: 'خدمة الموظفين', PRICING: 'التسعير',
DEPOSIT: 'التأمين', INSURANCE: 'التأمين', DAMAGE_CLAIM: 'مطالبة الأضرار',
RETURN_PROCESS: 'عملية الإرجاع', COMMUNICATION: 'التواصل',
ROADSIDE_ASSISTANCE: 'المساعدة على الطريق', BILLING: 'الفواتير', OTHER: 'أخرى',
} as Record<string, string>,
},
} as const
// ─── Types ─────────────────────────────────────────────────────────────────────
interface Complaint {
id: string
companyId: string
reservationId: string | null
reviewId: string | null
customerId: string | null
severity: string
status: string
category: string
subject: string
description: string | null
resolution: string | null
notes: string | null
assignedTo: string | null
resolvedAt: string | null
createdAt: string
reservation: { id: string; startDate: string; status: string; vehicle: { make: string; model: string; year: number } } | null
customer: { id: string; firstName: string; lastName: string; email: string } | null
review: { id: string; overallRating: number } | null
}
interface ComplaintsResponse {
data: Complaint[]
meta: { total: number; page: number; pageSize: number; totalPages: number }
}
const CATEGORIES = [
'BOOKING', 'PICKUP', 'VEHICLE_CLEANLINESS', 'VEHICLE_CONDITION', 'STAFF_SERVICE',
'PRICING', 'DEPOSIT', 'INSURANCE', 'DAMAGE_CLAIM', 'RETURN_PROCESS',
'COMMUNICATION', 'ROADSIDE_ASSISTANCE', 'BILLING', 'OTHER',
]
function severityBadgeClass(sev: string) {
if (sev === 'LEVEL_3') return 'badge-red'
if (sev === 'LEVEL_2') return 'badge-amber'
return 'badge-gray'
}
function statusBadgeClass(status: string) {
if (status === 'RESOLVED' || status === 'CLOSED') return 'badge-green'
if (status === 'INVESTIGATING') return 'badge-blue'
return 'badge-amber'
}
// ─── Main page ─────────────────────────────────────────────────────────────────
export default function ComplaintsPage() {
const { language } = useDashboardI18n()
const t = copy[language as keyof typeof copy] ?? copy.en
const [complaints, setComplaints] = useState<Complaint[]>([])
const [meta, setMeta] = useState({ total: 0, page: 1, pageSize: 20, totalPages: 1 })
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filterStatus, setFilterStatus] = useState('')
const [filterSeverity, setFilterSeverity] = useState('')
const [page, setPage] = useState(1)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)
// Create form state
const [formReservationId, setFormReservationId] = useState('')
const [formCategory, setFormCategory] = useState('')
const [formSeverity, setFormSeverity] = useState('LEVEL_1')
const [formSubject, setFormSubject] = useState('')
const [formDescription, setFormDescription] = useState('')
const [formCreating, setFormCreating] = useState(false)
// Edit state (for expanded complaint)
const [editStatus, setEditStatus] = useState<Record<string, string>>({})
const [editNotes, setEditNotes] = useState<Record<string, string>>({})
const [editResolution, setEditResolution] = useState<Record<string, string>>({})
const [savingId, setSavingId] = useState<string | null>(null)
async function loadData() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({ page: String(page), pageSize: '20' })
if (filterStatus) params.set('status', filterStatus)
if (filterSeverity) params.set('severity', filterSeverity)
const data = await apiFetch<ComplaintsResponse>(`/complaints?${params}`)
setComplaints(data.data ?? [])
setMeta(data.meta ?? { total: 0, page: 1, pageSize: 20, totalPages: 1 })
} catch {
setError(t.errorLoad)
} finally {
setLoading(false)
}
}
useEffect(() => { loadData() }, [page, filterStatus, filterSeverity])
function toggleExpand(id: string, complaint: Complaint) {
if (expandedId === id) {
setExpandedId(null)
} else {
setExpandedId(id)
setEditStatus((prev) => ({ ...prev, [id]: complaint.status }))
setEditNotes((prev) => ({ ...prev, [id]: complaint.notes ?? '' }))
setEditResolution((prev) => ({ ...prev, [id]: complaint.resolution ?? '' }))
}
}
async function handleCreate() {
if (!formCategory || !formSubject.trim()) return
setFormCreating(true)
try {
await apiFetch('/complaints', {
method: 'POST',
body: JSON.stringify({
reservationId: formReservationId.trim() || undefined,
category: formCategory,
severity: formSeverity,
subject: formSubject.trim(),
description: formDescription.trim() || undefined,
}),
})
setShowCreate(false)
setFormReservationId('')
setFormCategory('')
setFormSeverity('LEVEL_1')
setFormSubject('')
setFormDescription('')
await loadData()
} catch {
alert(t.errorCreate)
} finally {
setFormCreating(false)
}
}
async function handleUpdate(id: string) {
setSavingId(id)
try {
await apiFetch(`/complaints/${id}`, {
method: 'PATCH',
body: JSON.stringify({
status: editStatus[id],
notes: editNotes[id] || undefined,
resolution: editResolution[id] || undefined,
}),
})
await loadData()
} catch {
alert(t.errorUpdate)
} finally {
setSavingId(null)
}
}
const isRtl = language === 'ar'
return (
<div className={`space-y-6 ${isRtl ? 'rtl' : 'ltr'}`}>
{/* Header */}
<div className="rdg-page-heading flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-blue-950 dark:text-white">{t.heading}</h1>
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">{t.subtitle}</p>
</div>
<button onClick={() => setShowCreate((v) => !v)} className="btn-primary">
<Plus className="h-4 w-4" />
{t.newComplaint}
</button>
</div>
{/* Create form */}
{showCreate && (
<div className="card p-6 space-y-4">
<h2 className="text-lg font-semibold text-blue-950 dark:text-white">{t.createTitle}</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldReservationId}</label>
<input
type="text"
value={formReservationId}
onChange={(e) => setFormReservationId(e.target.value)}
className="input-field"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldCategory}</label>
<select
value={formCategory}
onChange={(e) => setFormCategory(e.target.value)}
className="input-field"
>
<option value=""> select </option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{t.categories[c] ?? c}</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldSeverity}</label>
<select value={formSeverity} onChange={(e) => setFormSeverity(e.target.value)} className="input-field">
<option value="LEVEL_1">{t.sevL1}</option>
<option value="LEVEL_2">{t.sevL2}</option>
<option value="LEVEL_3">{t.sevL3}</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldSubject}</label>
<input
type="text"
value={formSubject}
onChange={(e) => setFormSubject(e.target.value)}
className="input-field"
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldDescription}</label>
<textarea
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
rows={3}
className="input-field resize-none"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={formCreating || !formCategory || !formSubject.trim()}
className="btn-primary"
>
{formCreating ? t.creating : t.create}
</button>
<button onClick={() => setShowCreate(false)} className="btn-secondary">{t.cancel}</button>
</div>
</div>
)}
{/* Filters */}
<div className="card p-4">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-stone-700 dark:text-slate-300">{t.filterStatus}</label>
<select
value={filterStatus}
onChange={(e) => { setFilterStatus(e.target.value); setPage(1) }}
className="input-field w-auto"
>
<option value="">{t.allStatuses}</option>
<option value="OPEN">{t.statusOpen}</option>
<option value="INVESTIGATING">{t.statusInvestigating}</option>
<option value="RESOLVED">{t.statusResolved}</option>
<option value="CLOSED">{t.statusClosed}</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-stone-700 dark:text-slate-300">{t.filterSeverity}</label>
<select
value={filterSeverity}
onChange={(e) => { setFilterSeverity(e.target.value); setPage(1) }}
className="input-field w-auto"
>
<option value="">{t.allSeverities}</option>
<option value="LEVEL_1">{t.sevL1}</option>
<option value="LEVEL_2">{t.sevL2}</option>
<option value="LEVEL_3">{t.sevL3}</option>
</select>
</div>
</div>
</div>
{/* List */}
{loading ? (
<div className="card p-10 text-center text-stone-400 dark:text-slate-500">Loading</div>
) : error ? (
<div className="card p-10 text-center text-red-500">{error}</div>
) : complaints.length === 0 ? (
<div className="card p-10 text-center text-stone-400 dark:text-slate-500">{t.noComplaints}</div>
) : (
<div className="space-y-3">
{complaints.map((complaint) => {
const isExpanded = expandedId === complaint.id
const customerName = complaint.customer
? `${complaint.customer.firstName} ${complaint.customer.lastName}`
: null
return (
<div key={complaint.id} className="card overflow-hidden">
<button
onClick={() => toggleExpand(complaint.id, complaint)}
className="flex w-full items-start justify-between p-5 text-left hover:bg-stone-50 dark:hover:bg-blue-950/30"
>
<div className="flex flex-1 flex-wrap items-center gap-3">
<span className={severityBadgeClass(complaint.severity)}>
<AlertTriangle className="mr-1 h-3 w-3" />
{complaint.severity === 'LEVEL_1' ? t.sevL1 : complaint.severity === 'LEVEL_2' ? t.sevL2 : t.sevL3}
</span>
<span className={statusBadgeClass(complaint.status)}>
{complaint.status === 'OPEN' ? t.statusOpen
: complaint.status === 'INVESTIGATING' ? t.statusInvestigating
: complaint.status === 'RESOLVED' ? t.statusResolved
: t.statusClosed}
</span>
<span className="badge-gray">{t.categories[complaint.category] ?? complaint.category}</span>
<span className="font-medium text-blue-950 dark:text-white">{complaint.subject}</span>
{customerName && <span className="text-sm text-stone-500 dark:text-slate-400">{customerName}</span>}
{complaint.reservationId && (
<span className="font-mono text-xs text-stone-400 dark:text-slate-500">
{complaint.reservationId.slice(0, 8)}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-3">
<span className="text-xs text-stone-400 dark:text-slate-500">
{new Date(complaint.createdAt).toLocaleDateString()}
</span>
{isExpanded ? <ChevronUp className="h-4 w-4 text-stone-400" /> : <ChevronDown className="h-4 w-4 text-stone-400" />}
</div>
</button>
{/* Expanded detail */}
{isExpanded && (
<div className="border-t border-stone-100 p-5 dark:border-blue-900/30 space-y-4">
{complaint.description && (
<div>
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.description}</p>
<p className="text-sm text-stone-700 dark:text-slate-300">{complaint.description}</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-3">
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.updateStatus}</label>
<select
value={editStatus[complaint.id] ?? complaint.status}
onChange={(e) => setEditStatus((prev) => ({ ...prev, [complaint.id]: e.target.value }))}
className="input-field"
>
<option value="OPEN">{t.statusOpen}</option>
<option value="INVESTIGATING">{t.statusInvestigating}</option>
<option value="RESOLVED">{t.statusResolved}</option>
<option value="CLOSED">{t.statusClosed}</option>
</select>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.notes}</label>
<textarea
value={editNotes[complaint.id] ?? ''}
onChange={(e) => setEditNotes((prev) => ({ ...prev, [complaint.id]: e.target.value }))}
rows={2}
className="input-field resize-none"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.resolution}</label>
<textarea
value={editResolution[complaint.id] ?? ''}
onChange={(e) => setEditResolution((prev) => ({ ...prev, [complaint.id]: e.target.value }))}
rows={2}
className="input-field resize-none"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => handleUpdate(complaint.id)}
disabled={savingId === complaint.id}
className="btn-primary text-sm"
>
{savingId === complaint.id ? t.saving : t.save}
</button>
<button onClick={() => setExpandedId(null)} className="btn-secondary text-sm">{t.cancel}</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
{/* Pagination */}
{meta.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="btn-secondary text-sm"
>
</button>
<span className="text-sm text-stone-600 dark:text-slate-400">
{page} / {meta.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(meta.totalPages, p + 1))}
disabled={page >= meta.totalPages}
className="btn-secondary text-sm"
>
</button>
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
'use client'
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 ReservationRow = {
id: string
contractNumber: string | null
invoiceNumber: string | null
status: string
paymentStatus: string
startDate: string
endDate: string
totalAmount: number
customer: { firstName: string; lastName: string; email: string }
vehicle: { make: string; model: string; licensePlate: string }
}
export default function ContractsPage() {
const { language } = useDashboardI18n()
const [rows, setRows] = useState<ReservationRow[]>([])
const [search, setSearch] = useState('')
const [error, setError] = useState<string | null>(null)
const copy = {
en: {
heading: 'Contracts',
subtitle: 'Generate or reopen rental contracts from any booking.',
search: 'Search customer, vehicle, plate, contract number…',
booking: 'Booking',
customer: 'Customer',
vehicle: 'Vehicle',
dates: 'Dates',
contract: 'Contract',
invoice: 'Invoice',
total: 'Total',
status: 'Status',
action: 'Action',
open: 'Open contract',
generate: 'Generate contract',
empty: 'No bookings found.',
},
fr: {
heading: 'Contrats',
subtitle: 'Générez ou rouvrez un contrat de location depuis nimporte quelle réservation.',
search: 'Rechercher client, véhicule, plaque, numéro de contrat…',
booking: 'Réservation',
customer: 'Client',
vehicle: 'Véhicule',
dates: 'Dates',
contract: 'Contrat',
invoice: 'Facture',
total: 'Total',
status: 'Statut',
action: 'Action',
open: 'Ouvrir le contrat',
generate: 'Générer le contrat',
empty: 'Aucune réservation trouvée.',
},
ar: {
heading: 'العقود',
subtitle: 'أنشئ أو افتح عقد التأجير من أي حجز.',
search: 'ابحث بالعميل أو السيارة أو اللوحة أو رقم العقد…',
booking: 'الحجز',
customer: 'العميل',
vehicle: 'المركبة',
dates: 'التواريخ',
contract: 'العقد',
invoice: 'الفاتورة',
total: 'الإجمالي',
status: 'الحالة',
action: 'الإجراء',
open: 'فتح العقد',
generate: 'إنشاء العقد',
empty: 'لم يتم العثور على حجوزات.',
},
}[language]
useEffect(() => {
apiFetch<ReservationRow[]>('/reservations?pageSize=100')
.then((data) => setRows(data ?? []))
.catch((err) => setError(err.message ?? 'Failed to load contracts'))
}, [])
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return rows
return rows.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.contractNumber ?? '').toLowerCase().includes(q) ||
(row.invoiceNumber ?? '').toLowerCase().includes(q),
)
}, [rows, search])
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const formatDate = (iso: string) => new Date(iso).toLocaleString(localeCode, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
return (
<div className="space-y-6">
<div className="rdg-page-heading 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.heading}</h2>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={copy.search}
className="input-field w-full lg:max-w-md"
/>
</div>
<div className="card overflow-hidden">
{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.dates}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.contract}</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.status}</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.action}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{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-600">
<p>{formatDate(row.startDate)}</p>
<p>{formatDate(row.endDate)}</p>
</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.contractNumber ?? '—'}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.invoiceNumber ?? '—'}</td>
<td className="px-6 py-4 text-sm">
<div className="flex flex-col gap-2">
<span className="badge-blue">{row.status}</span>
<span className="badge-gray">{row.paymentStatus}</span>
</div>
</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
<td className="px-6 py-4 text-right">
<Link href={`/contracts/${row.id}`} className="text-sm font-semibold text-blue-700 hover:underline">
{row.contractNumber ? copy.open : copy.generate}
</Link>
</td>
</tr>
))}
{filteredRows.length === 0 && (
<tr>
<td colSpan={8} className="px-6 py-10 text-center text-sm text-slate-400">{copy.empty}</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,124 @@
'use client'
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface CustomerRow {
id: string
firstName: string
lastName: string
email: string
phone: string | null
flagged: boolean
licenseValidationStatus: string
licenseImageUrl: string | null
}
export default function CustomersPage() {
const { language } = useDashboardI18n()
const [rows, setRows] = useState<CustomerRow[]>([])
const [error, setError] = useState<string | null>(null)
const copy = {
en: {
title: 'Customers',
subtitle: 'A company CRM with license validation and risk flags.',
customer: 'Customer',
contact: 'Contact',
license: 'License',
flags: 'Flags',
imageUploaded: 'Image uploaded',
imageMissing: 'No image',
noPhone: 'No phone',
flagged: 'Flagged',
clear: 'Clear',
empty: 'No customers yet.',
},
fr: {
title: 'Clients',
subtitle: 'CRM de lentreprise avec validation des permis et indicateurs de risque.',
customer: 'Client',
contact: 'Contact',
license: 'Permis',
flags: 'Signalements',
imageUploaded: 'Image téléversée',
imageMissing: 'Aucune image',
noPhone: 'Pas de téléphone',
flagged: 'Signalé',
clear: 'Aucun risque',
empty: 'Aucun client pour le moment.',
},
ar: {
title: 'العملاء',
subtitle: 'نظام إدارة عملاء خاص بالشركة مع التحقق من الرخص ومؤشرات المخاطر.',
customer: 'العميل',
contact: 'التواصل',
license: 'الرخصة',
flags: 'العلامات',
imageUploaded: 'تم رفع الصورة',
imageMissing: 'لا توجد صورة',
noPhone: 'لا يوجد هاتف',
flagged: 'تم تمييزه',
clear: 'لا توجد مخاطر',
empty: 'لا يوجد عملاء حتى الآن.',
},
}[language]
useEffect(() => {
apiFetch<CustomerRow[]>('/customers?pageSize=100')
.then((result) => setRows(result ?? []))
.catch((err) => setError(err.message))
}, [])
return (
<div className="space-y-6">
<div className="rdg-page-heading">
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div>
<div className="card overflow-hidden">
{error ? (
<div className="p-8 text-sm text-red-600">{error}</div>
) : (
<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.customer}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.contact}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.license}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.flags}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rows.map((row) => (
<tr key={row.id}>
<td className="px-6 py-4 text-sm font-semibold text-slate-900">{row.firstName} {row.lastName}</td>
<td className="px-6 py-4">
<p className="text-sm text-slate-700">{row.email}</p>
<p className="text-xs text-slate-500">{row.phone ?? copy.noPhone}</p>
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap items-center gap-2">
<span className="badge-gray">{row.licenseValidationStatus}</span>
<span className={row.licenseImageUrl ? 'badge-green' : 'badge-gray'}>
{row.licenseImageUrl ? copy.imageUploaded : copy.imageMissing}
</span>
</div>
</td>
<td className="px-6 py-4">{row.flagged ? <span className="badge-red">{copy.flagged}</span> : <span className="badge-green">{copy.clear}</span>}</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={4} className="px-6 py-10 text-center text-sm text-slate-400">{copy.empty}</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,838 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import Image from 'next/image'
import { ArrowLeft, Edit, X, Check, Upload, Trash2, CalendarDays, DollarSign, Info, Wrench, Plus, ChevronDown, ChevronUp } from 'lucide-react'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import VehicleCalendar from '@/components/VehicleCalendar'
import VehiclePricingManager from '@/components/VehiclePricingManager'
import { useDashboardI18n } from '@/components/I18nProvider'
interface VehicleDetail {
id: string
make: string
model: string
year: number
category: string
dailyRate: number
status: string
color: string
transmission: string
fuelType: string
seats: number
mileage: number | null
licensePlate: string
vin: string | null
photos: string[]
notes: string | null
isPublished: boolean
pickupLocations: string[]
allowDifferentDropoff: boolean
dropoffLocations: string[]
}
const PRESET_COLORS = ['White', 'Black', 'Silver', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Brown', 'Beige', 'Gold']
const PRESET_MAKES = ['Audi', 'BMW', 'Chevrolet', 'Citroën', 'Dacia', 'Fiat', 'Ford', 'Honda', 'Hyundai', 'Kia', 'Mazda', 'Mercedes-Benz', 'Nissan', 'Opel', 'Peugeot', 'Renault', 'Seat', 'Skoda', 'Suzuki', 'Toyota', 'Volkswagen', 'Volvo']
const MODELS_BY_MAKE: Record<string, string[]> = {
'Audi': ['A1', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'Q2', 'Q3', 'Q5', 'Q7', 'Q8', 'TT'],
'BMW': ['116i', '118i', '120i', '218i', '316i', '318i', '320i', '325i', 'X1', 'X2', 'X3', 'X4', 'X5', 'X6', 'X7'],
'Chevrolet': ['Aveo', 'Captiva', 'Cruze', 'Malibu', 'Spark', 'Trax'],
'Citroën': ['Berlingo', 'C1', 'C3', 'C3 Aircross', 'C4', 'C4 Cactus', 'C5', 'C5 Aircross', 'Jumpy', 'SpaceTourer'],
'Dacia': ['Dokker', 'Duster', 'Jogger', 'Lodgy', 'Logan', 'Sandero', 'Spring'],
'Fiat': ['500', '500X', 'Bravo', 'Doblo', 'Ducato', 'Panda', 'Punto', 'Tipo'],
'Ford': ['B-Max', 'C-Max', 'EcoSport', 'Edge', 'Explorer', 'Fiesta', 'Focus', 'Fusion', 'Kuga', 'Mondeo', 'Mustang', 'Puma', 'Ranger', 'S-Max', 'Transit'],
'Honda': ['Accord', 'Civic', 'CR-V', 'HR-V', 'Jazz', 'Pilot'],
'Hyundai': ['Accent', 'Creta', 'Elantra', 'i10', 'i20', 'i30', 'ix35', 'Kona', 'Santa Fe', 'Sonata', 'Tucson'],
'Kia': ['Carnival', 'Ceed', 'Niro', 'Optima', 'Picanto', 'Rio', 'Sorento', 'Soul', 'Sportage', 'Stinger'],
'Mazda': ['2', '3', '6', 'CX-3', 'CX-5', 'CX-9', 'MX-5'],
'Mercedes-Benz': ['A-Class', 'B-Class', 'C-Class', 'CLA', 'E-Class', 'GLA', 'GLB', 'GLC', 'GLE', 'GLS', 'S-Class', 'Sprinter', 'Vito'],
'Nissan': ['Juke', 'Leaf', 'Micra', 'Murano', 'Navara', 'Note', 'Pathfinder', 'Patrol', 'Pulsar', 'Qashqai', 'X-Trail'],
'Opel': ['Astra', 'Corsa', 'Crossland', 'Grandland', 'Insignia', 'Meriva', 'Mokka', 'Zafira'],
'Peugeot': ['108', '208', '2008', '3008', '308', '4008', '408', '5008', '508', 'Boxer', 'Expert', 'Partner', 'Traveller'],
'Renault': ['Arkana', 'Captur', 'Clio', 'Duster', 'Espace', 'Grand Scenic', 'Kadjar', 'Kangoo', 'Koleos', 'Laguna', 'Master', 'Megane', 'Scenic', 'Talisman', 'Trafic', 'Twingo', 'Zoe'],
'Seat': ['Arona', 'Arosa', 'Ateca', 'Ibiza', 'Leon', 'Tarraco', 'Toledo'],
'Skoda': ['Fabia', 'Karoq', 'Kodiaq', 'Octavia', 'Rapid', 'Scala', 'Superb', 'Yeti'],
'Suzuki': ['Alto', 'Baleno', 'Celerio', 'Ignis', 'Jimny', 'S-Cross', 'Swift', 'Vitara'],
'Toyota': ['Auris', 'Avensis', 'Aygo', 'Camry', 'Corolla', 'C-HR', 'Fortuner', 'Hilux', 'Land Cruiser', 'Prius', 'RAV4', 'Rush', 'Urban Cruiser', 'Yaris'],
'Volkswagen': ['Amarok', 'Arteon', 'Caddy', 'Golf', 'ID.3', 'ID.4', 'Passat', 'Polo', 'Sharan', 'T-Cross', 'T-Roc', 'Tiguan', 'Touareg', 'Touran', 'Transporter', 'Up'],
'Volvo': ['S60', 'S90', 'V40', 'V60', 'V90', 'XC40', 'XC60', 'XC90'],
}
function parseLocationInput(value: string) {
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
}
function formatLocationInput(values: string[]) {
return Array.isArray(values) ? values.join(', ') : ''
}
function initMakeSelect(make: string) {
return PRESET_MAKES.includes(make) ? make : (make ? 'custom' : '')
}
function initModelSelect(make: string, model: string) {
const presets = MODELS_BY_MAKE[make]
if (!presets) return model ? 'custom' : ''
return presets.includes(model) ? model : (model ? 'custom' : '')
}
function initColorSelect(color: string) {
return PRESET_COLORS.includes(color) ? color : (color ? 'custom' : '')
}
interface MaintenanceLog {
id: string
type: string
description: string | null
cost: number | null
mileage: number | null
performedAt: string
nextDueAt: string | null
nextDueMileage: number | null
}
const ROUTINE_TYPES = [
{ key: 'Oil Change', intervalMonths: 6 },
{ key: 'Technical Inspection', intervalMonths: 12 },
{ key: 'Vehicle Registration', intervalMonths: 12 },
{ key: 'Car Tax', intervalMonths: 12 },
{ key: 'Tire Rotation', intervalMonths: 6 },
{ key: 'Brake Inspection', intervalMonths: 12 },
{ key: 'Air Filter', intervalMonths: 12 },
{ key: 'Cabin Filter', intervalMonths: 12 },
{ key: 'Battery Check', intervalMonths: 12 },
{ key: 'Belt / Chain Service', intervalMonths: 24 },
{ key: 'Coolant Flush', intervalMonths: 24 },
{ key: 'Transmission Service', intervalMonths: 24 },
]
function serviceStatus(log: MaintenanceLog | undefined, currentMileage: number | null): 'none' | 'overdue' | 'due-soon' | 'ok' {
if (!log) return 'none'
const now = new Date()
let worstLevel: 'ok' | 'due-soon' | 'overdue' = 'ok'
if (log.nextDueAt) {
const diffDays = (new Date(log.nextDueAt).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
if (diffDays < 0) return 'overdue'
if (diffDays <= 30) worstLevel = 'due-soon'
}
if (log.nextDueMileage != null && currentMileage != null) {
const kmLeft = log.nextDueMileage - currentMileage
if (kmLeft <= 0) return 'overdue'
if (kmLeft <= 500) worstLevel = 'due-soon'
}
return worstLevel
}
function StatusPill({ status }: { status: ReturnType<typeof serviceStatus> }) {
const { dict } = useDashboardI18n()
const vd = dict.vehicleDetail
if (status === 'none') return <span className="badge-gray text-xs">{vd.statusNone}</span>
if (status === 'overdue') return <span className="badge-red text-xs">{vd.statusOverdue}</span>
if (status === 'due-soon') return <span className="badge-amber text-xs">{vd.statusDueSoon}</span>
return <span className="badge-green text-xs">{vd.statusOk}</span>
}
function MaintenanceRow({ vehicleId, typeKey, intervalMonths, currentMileage, log, onLogged }: {
vehicleId: string
typeKey: string
intervalMonths: number
currentMileage: number | null
log: MaintenanceLog | undefined
onLogged: () => void
}) {
const { dict } = useDashboardI18n()
const vd = dict.vehicleDetail
const [open, setOpen] = useState(false)
const today = new Date().toISOString().slice(0, 10)
const defaultNextDue = (() => {
const d = new Date()
d.setMonth(d.getMonth() + intervalMonths)
return d.toISOString().slice(0, 10)
})()
const [form, setForm] = useState({
description: '', cost: '', mileage: '', performedAt: today,
nextDueAt: defaultNextDue, nextDueMileage: '',
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.performedAt) { setError(vd.dateRequired); return }
setSaving(true)
setError(null)
try {
await apiFetch(`/vehicles/${vehicleId}/maintenance`, {
method: 'POST',
body: JSON.stringify({
type: typeKey,
description: form.description || undefined,
cost: form.cost ? Math.round(parseFloat(form.cost) * 100) : undefined,
mileage: form.mileage ? parseInt(form.mileage) : undefined,
performedAt: new Date(form.performedAt).toISOString(),
nextDueAt: form.nextDueAt ? new Date(form.nextDueAt).toISOString() : undefined,
nextDueMileage: form.nextDueMileage ? parseInt(form.nextDueMileage) : undefined,
}),
})
setForm({ description: '', cost: '', mileage: '', performedAt: today, nextDueAt: defaultNextDue, nextDueMileage: '' })
setOpen(false)
onLogged()
} catch (err: any) {
setError(err.message ?? vd.savingLabel)
} finally {
setSaving(false)
}
}
const status = serviceStatus(log, currentMileage)
const displayLabel = vd.routineTypes[typeKey] ?? typeKey
const nextDueText = (() => {
if (!log) return vd.noRecord
const parts: string[] = [`${vd.lastPrefix} ${new Date(log.performedAt).toLocaleDateString()}`]
if (log.mileage) parts.push(vd.atKm(log.mileage.toLocaleString()))
const dueParts: string[] = []
if (log.nextDueAt) dueParts.push(new Date(log.nextDueAt).toLocaleDateString())
if (log.nextDueMileage != null) dueParts.push(`${log.nextDueMileage.toLocaleString()} km`)
if (dueParts.length) parts.push(`${vd.duePrefix} ${dueParts.join(` ${vd.orSep} `)}`)
if (currentMileage != null && log.nextDueMileage != null) {
const kmLeft = log.nextDueMileage - currentMileage
parts.push(`(${kmLeft > 0 ? vd.kmLeft(kmLeft.toLocaleString()) : vd.overdueByKm})`)
}
return parts.join(' ')
})()
return (
<div className="rounded-xl border border-slate-100 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-white hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<Wrench className="w-4 h-4 text-slate-400 flex-shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900">{displayLabel}</p>
<p className="text-xs text-slate-400 mt-0.5 truncate">{nextDueText}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<StatusPill status={status} />
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-700 px-2 py-1 rounded-lg hover:bg-blue-50 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
{vd.logBtn}
{open ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
</div>
</div>
{open && (
<form onSubmit={handleSubmit} className="border-t border-slate-100 bg-slate-50 px-4 py-4 space-y-3">
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.serviceDateLabel}</label>
<input type="date" className="input-field text-sm" value={form.performedAt} onChange={(e) => setForm({ ...form, performedAt: e.target.value })} required />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.odometerAtService}</label>
<input type="number" className="input-field text-sm" placeholder={currentMileage ? String(currentMileage) : 'e.g. 52000'} min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.nextDueDateLabel}</label>
<input type="date" className="input-field text-sm" value={form.nextDueAt} onChange={(e) => setForm({ ...form, nextDueAt: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.nextDueKmLabel}</label>
<input type="number" className="input-field text-sm" placeholder="e.g. 60000" min="0" value={form.nextDueMileage} onChange={(e) => setForm({ ...form, nextDueMileage: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.costLabel}</label>
<input type="number" className="input-field text-sm" placeholder="0.00" min="0" step="0.01" value={form.cost} onChange={(e) => setForm({ ...form, cost: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.maintenanceNotesLabel}</label>
<input className="input-field text-sm" placeholder={vd.optional} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setOpen(false)} className="btn-secondary text-xs py-1.5 px-3">{vd.cancelBtn}</button>
<button type="submit" disabled={saving} className="btn-primary text-xs py-1.5 px-3">{saving ? vd.savingLabel : vd.saveLabel}</button>
</div>
</form>
)}
</div>
)
}
type Tab = 'details' | 'maintenance' | 'calendar' | 'pricing'
export default function FleetDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const searchParams = useSearchParams()
const { dict, language } = useDashboardI18n()
const vd = dict.vehicleDetail
const fl = dict.fleet
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<Tab>('details')
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [form, setForm] = useState<{
make: string; model: string; year: number; category: string
dailyRate: string; licensePlate: string; vin: string; color: string
seats: number; transmission: string; fuelType: string
status: string; notes: string; mileage: string
pickupLocations: string; allowDifferentDropoff: boolean; dropoffLocations: string
} | null>(null)
const [makeSelect, setMakeSelect] = useState('')
const [modelSelect, setModelSelect] = useState('')
const [colorSelect, setColorSelect] = useState('')
const [maintenanceLogs, setMaintenanceLogs] = useState<MaintenanceLog[]>([])
const fetchMaintenance = () => {
apiFetch<MaintenanceLog[]>(`/vehicles/${params.id}/maintenance`)
.then((logs) => setMaintenanceLogs(logs ?? []))
.catch(() => {})
}
useEffect(() => { fetchMaintenance() }, [params.id])
const [deletingPhotoIdx, setDeletingPhotoIdx] = useState<number | null>(null)
const [newPhotoFiles, setNewPhotoFiles] = useState<File[]>([])
const [newPhotoPreviews, setNewPhotoPreviews] = useState<string[]>([])
const [uploadingPhotos, setUploadingPhotos] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
apiFetch<VehicleDetail>(`/vehicles/${params.id}`)
.then(setVehicle)
.catch((err) => setError(err.message))
}, [params.id])
useEffect(() => {
if (searchParams.get('tab') === 'pricing') {
setActiveTab('pricing')
}
}, [searchParams])
const startEdit = () => {
if (!vehicle) return
setForm({
make: vehicle.make, model: vehicle.model, year: vehicle.year,
category: vehicle.category,
dailyRate: (vehicle.dailyRate / 100).toFixed(2),
licensePlate: vehicle.licensePlate,
vin: vehicle.vin ?? '',
color: vehicle.color ?? '', seats: vehicle.seats,
transmission: vehicle.transmission, fuelType: vehicle.fuelType,
status: vehicle.status, notes: vehicle.notes ?? '',
mileage: vehicle.mileage != null ? String(vehicle.mileage) : '',
pickupLocations: formatLocationInput(vehicle.pickupLocations),
allowDifferentDropoff: vehicle.allowDifferentDropoff,
dropoffLocations: formatLocationInput(vehicle.dropoffLocations),
})
setMakeSelect(initMakeSelect(vehicle.make))
setModelSelect(initModelSelect(vehicle.make, vehicle.model))
setColorSelect(initColorSelect(vehicle.color ?? ''))
setSaveError(null)
setEditing(true)
}
const cancelEdit = () => {
newPhotoPreviews.forEach((p) => URL.revokeObjectURL(p))
setNewPhotoFiles([])
setNewPhotoPreviews([])
setEditing(false)
setForm(null)
setSaveError(null)
}
const handleSave = async () => {
if (!form || !vehicle) return
if (!form.make) { setSaveError(vd.makeRequired); return }
if (!form.model) { setSaveError(vd.modelRequired); return }
if (!form.licensePlate) { setSaveError(vd.plateRequired); return }
const rate = parseFloat(form.dailyRate)
if (isNaN(rate) || rate < 0) { setSaveError(vd.rateInvalid); return }
if (form.allowDifferentDropoff && parseLocationInput(form.dropoffLocations).length === 0) {
setSaveError(fl.dropoffLocationsRequired)
return
}
setSaving(true)
setSaveError(null)
try {
const updated = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}`, {
method: 'PATCH',
body: JSON.stringify({
make: form.make, model: form.model, year: Number(form.year),
category: form.category, dailyRate: Math.round(rate * 100),
licensePlate: form.licensePlate, vin: form.vin.trim() || undefined, color: form.color,
seats: Number(form.seats), transmission: form.transmission,
fuelType: form.fuelType, status: form.status,
notes: form.notes || undefined,
mileage: form.mileage ? Number(form.mileage) : undefined,
pickupLocations: parseLocationInput(form.pickupLocations),
allowDifferentDropoff: form.allowDifferentDropoff,
dropoffLocations: form.allowDifferentDropoff ? parseLocationInput(form.dropoffLocations) : [],
}),
})
let finalVehicle = updated
if (newPhotoFiles.length > 0) {
setUploadingPhotos(true)
const fd = new FormData()
newPhotoFiles.forEach((f) => fd.append('photos', f))
finalVehicle = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}/photos`, { method: 'POST', body: fd })
newPhotoPreviews.forEach((p) => URL.revokeObjectURL(p))
setNewPhotoFiles([])
setNewPhotoPreviews([])
setUploadingPhotos(false)
}
setVehicle(finalVehicle)
setEditing(false)
setForm(null)
} catch (err: any) {
setSaveError(err.message ?? vd.failedSave)
setUploadingPhotos(false)
} finally {
setSaving(false)
}
}
const handleDeletePhoto = async (idx: number) => {
if (!vehicle) return
setDeletingPhotoIdx(idx)
try {
const updated = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}/photos/${idx}`, { method: 'DELETE' })
setVehicle(updated)
} catch (err: any) {
alert(err.message)
} finally {
setDeletingPhotoIdx(null)
}
}
const handleNewPhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
const totalExisting = vehicle?.photos.length ?? 0
const combined = [...newPhotoFiles, ...files].slice(0, 10 - totalExisting)
setNewPhotoFiles(combined)
setNewPhotoPreviews(combined.map((f) => URL.createObjectURL(f)))
e.target.value = ''
}
const removeNewPhoto = (i: number) => {
URL.revokeObjectURL(newPhotoPreviews[i])
setNewPhotoFiles(newPhotoFiles.filter((_, idx) => idx !== i))
setNewPhotoPreviews(newPhotoPreviews.filter((_, idx) => idx !== i))
}
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
if (!vehicle) return <div className="card p-6 text-sm text-slate-500">{vd.loadingVehicle}</div>
const presetModels = form && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : null
const totalPhotos = vehicle.photos.length + newPhotoFiles.length
const categoryLabel = fl.categoryLabels[vehicle.category as keyof typeof fl.categoryLabels] ?? vehicle.category
const statusLabel = fl.statusLabels[vehicle.status as keyof typeof fl.statusLabels] ?? vehicle.status
const fuelLabel = fl.fuelTypeLabels[vehicle.fuelType as keyof typeof fl.fuelTypeLabels] ?? vehicle.fuelType
const transLabel = vehicle.transmission === 'MANUAL' ? fl.manual : fl.automatic
const pricingTabLabel = language === 'fr' ? 'Tarifs' : language === 'ar' ? 'التسعير' : 'Pricing'
return (
<div className="space-y-6">
{/* Header */}
<div className="rdg-page-heading flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={() => router.back()} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<ArrowLeft className="w-4 h-4" />
</button>
<div>
<h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2>
<p className="text-sm text-slate-500 mt-0.5">
{vehicle.year} · {vehicle.licensePlate}
{vehicle.vin ? ` · ${vehicle.vin}` : ''}
{' · '}
{statusLabel}
</p>
</div>
</div>
{!editing ? (
<button onClick={startEdit} className="btn-secondary flex items-center gap-2">
<Edit className="w-4 h-4" /> {vd.editBtn}
</button>
) : (
<div className="flex gap-2">
<button onClick={cancelEdit} className="btn-secondary flex items-center gap-2">
<X className="w-4 h-4" /> {vd.cancelBtn}
</button>
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
<Check className="w-4 h-4" />
{saving ? (uploadingPhotos ? vd.uploadingPhotosBtn : vd.savingBtn) : vd.saveBtn}
</button>
</div>
)}
</div>
{saveError && (
<div className="px-4 py-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{saveError}</div>
)}
{/* Tab bar */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-800">
{(['details', 'maintenance', 'calendar', 'pricing'] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={[
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === tab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300',
].join(' ')}
>
{tab === 'details' && <><Info className="w-4 h-4" /> {vd.tabDetails}</>}
{tab === 'maintenance' && (
<>
<Wrench className="w-4 h-4" /> {vd.tabMaintenance}
{maintenanceLogs.length === 0 && <span className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full bg-orange-100 text-orange-700 text-[10px] font-bold">!</span>}
</>
)}
{tab === 'calendar' && <><CalendarDays className="w-4 h-4" /> {vd.tabCalendar}</>}
{tab === 'pricing' && <><DollarSign className="w-4 h-4" /> {pricingTabLabel}</>}
</button>
))}
</div>
{activeTab === 'details' && (
<div className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">
{/* Photos */}
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900 mb-4">{vd.photosHeading}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{vehicle.photos.map((photo, index) => (
<div key={`${photo}-${index}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group">
<Image src={photo} alt={`${vehicle.make} ${vehicle.model} photo ${index + 1}`} fill className="object-cover" unoptimized={!photo.includes('res.cloudinary.com')} priority={index === 0} />
{editing && (
<button
onClick={() => handleDeletePhoto(index)}
disabled={deletingPhotoIdx === index}
className="absolute top-2 right-2 p-1.5 bg-red-600 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-60"
>
{deletingPhotoIdx === index
? <div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <Trash2 className="w-3.5 h-3.5" />}
</button>
)}
</div>
))}
{editing && newPhotoPreviews.map((src, i) => (
<div key={`new-${i}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group ring-2 ring-blue-400">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={`new photo ${i + 1}`} className="object-cover w-full h-full" />
<button
onClick={() => removeNewPhoto(i)}
className="absolute top-2 right-2 p-1.5 bg-blue-950/60 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3.5 h-3.5" />
</button>
<span className="absolute bottom-2 left-2 text-xs bg-blue-600 text-white px-2 py-0.5 rounded-full">{vd.newPhotoBadge}</span>
</div>
))}
{vehicle.photos.length === 0 && !editing && (
<div className="text-sm text-slate-400">{vd.noPhotosYet}</div>
)}
</div>
{editing && totalPhotos < 10 && (
<div className="mt-4">
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleNewPhotoChange} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
>
<Upload className="w-4 h-4" />
{totalPhotos === 0 ? vd.clickToAddPhotos : vd.addMorePhotos(totalPhotos)}
</button>
</div>
)}
{editing && totalPhotos >= 10 && (
<p className="mt-3 text-xs text-slate-400 text-center">{vd.maxPhotosReached}</p>
)}
</div>
{/* Details / Edit form */}
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900 mb-4">{vd.vehicleDetailsHeading}</h3>
{!editing || !form ? (
<dl className="space-y-3 text-sm">
<div><dt className="text-slate-500">{vd.labelCategory}</dt><dd className="text-slate-900">{categoryLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelDailyRate}</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{vd.labelStatus}</dt><dd className="text-slate-900">{statusLabel}</dd></div>
<div><dt className="text-slate-500">{vd.vinLabel}</dt><dd className="text-slate-900">{vehicle.vin || '—'}</dd></div>
<div><dt className="text-slate-500">{vd.labelSeats}</dt><dd className="text-slate-900">{vehicle.seats}</dd></div>
<div><dt className="text-slate-500">{vd.labelTransmission}</dt><dd className="text-slate-900">{transLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelFuelType}</dt><dd className="text-slate-900">{fuelLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelColor}</dt><dd className="text-slate-900">{vehicle.color || '—'}</dd></div>
<div><dt className="text-slate-500">{vd.labelOdometer}</dt><dd className="text-slate-900">{vehicle.mileage != null ? `${vehicle.mileage.toLocaleString()} km` : '—'}</dd></div>
<div><dt className="text-slate-500">{fl.pickupLocations}</dt><dd className="text-slate-900">{vehicle.pickupLocations.length > 0 ? vehicle.pickupLocations.join(', ') : '—'}</dd></div>
<div><dt className="text-slate-500">{fl.allowDifferentDropoff}</dt><dd className="text-slate-900">{vehicle.allowDifferentDropoff ? fl.differentDropoffAvailable : fl.sameDropoffOnly}</dd></div>
{vehicle.allowDifferentDropoff && (
<div><dt className="text-slate-500">{fl.dropoffLocations}</dt><dd className="text-slate-900">{vehicle.dropoffLocations.length > 0 ? vehicle.dropoffLocations.join(', ') : '—'}</dd></div>
)}
<div><dt className="text-slate-500">{vd.labelNotes}</dt><dd className="text-slate-900">{vehicle.notes ?? '—'}</dd></div>
</dl>
) : (
<div className="space-y-4 text-sm">
{/* Make */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.makeLabel}</label>
<select
className="input-field"
value={makeSelect}
onChange={(e) => {
setMakeSelect(e.target.value)
setModelSelect('')
if (e.target.value !== 'custom') setForm({ ...form, make: e.target.value, model: '' })
else setForm({ ...form, make: '', model: '' })
}}
>
<option value="">{vd.selectMake}</option>
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">{vd.other}</option>
</select>
{makeSelect === 'custom' && (
<input className="input-field mt-2" placeholder={vd.typeMake} value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} />
)}
</div>
{/* Model */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.modelLabel}</label>
{!presetModels ? (
<input className="input-field" placeholder={vd.typeModel} value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
) : (
<>
<select
className="input-field"
value={modelSelect}
onChange={(e) => {
setModelSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, model: e.target.value })
else setForm({ ...form, model: '' })
}}
>
<option value="">{vd.selectModel}</option>
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">{vd.other}</option>
</select>
{modelSelect === 'custom' && (
<input className="input-field mt-2" placeholder={vd.typeModel} value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
)}
</>
)}
</div>
{/* Year & Category */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.yearLabel}</label>
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.fuelTypeLabel.replace('Type', '').trim() !== '' ? vd.fuelTypeLabel : fl.category}</label>
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
<option key={c} value={c}>{fl.categoryLabels[c]}</option>
))}
</select>
</div>
</div>
{/* Daily Rate, License Plate & VIN */}
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.dailyRateLabel}</label>
<input type="number" className="input-field" min="0" step="0.01" value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.licensePlateLabel}</label>
<input className="input-field" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.vinLabel}</label>
<input className="input-field" placeholder="1HGCM82633A123456" value={form.vin} onChange={(e) => setForm({ ...form, vin: e.target.value })} />
</div>
{/* Status */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.statusLabel}</label>
<select className="input-field" value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
{(['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE'] as const).map((s) => (
<option key={s} value={s}>{fl.statusLabels[s]}</option>
))}
</select>
</div>
{/* Color & Seats */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.colorLabel}</label>
<select
className="input-field"
value={colorSelect}
onChange={(e) => {
setColorSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value })
else setForm({ ...form, color: '' })
}}
>
<option value="">{vd.selectColor}</option>
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
<option value="custom">{vd.other}</option>
</select>
{colorSelect === 'custom' && (
<input className="input-field mt-2" placeholder={vd.typeColor} value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
)}
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.seatsLabel}</label>
<input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} />
</div>
</div>
{/* Transmission & Fuel */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.transmissionLabel}</label>
<select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}>
<option value="MANUAL">{fl.manual}</option>
<option value="AUTOMATIC">{fl.automatic}</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.fuelTypeLabel}</label>
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
{(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const).map((f) => (
<option key={f} value={f}>{fl.fuelTypeLabels[f]}</option>
))}
</select>
</div>
</div>
{/* Odometer */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.odometerLabel}</label>
<input type="number" className="input-field" placeholder="e.g. 45000" min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
</div>
<div className="space-y-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{fl.pickupLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
placeholder={fl.pickupLocationsPlaceholder}
value={form.pickupLocations}
onChange={(e) => setForm({ ...form, pickupLocations: e.target.value })}
/>
<p className="mt-1 text-xs text-slate-400">{fl.locationHint}</p>
</div>
<label className="flex items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm font-medium text-slate-700">
<input
type="checkbox"
checked={form.allowDifferentDropoff}
onChange={(e) => setForm({
...form,
allowDifferentDropoff: e.target.checked,
dropoffLocations: e.target.checked ? form.dropoffLocations : '',
})}
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<span>{fl.allowDifferentDropoff}</span>
</label>
{form.allowDifferentDropoff && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{fl.dropoffLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
placeholder={fl.dropoffLocationsPlaceholder}
value={form.dropoffLocations}
onChange={(e) => setForm({ ...form, dropoffLocations: e.target.value })}
/>
<p className="mt-1 text-xs text-slate-400">{fl.locationHint}</p>
</div>
)}
</div>
{/* Notes */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.notesLabel}</label>
<textarea className="input-field resize-none" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder={vd.optionalNotes} />
</div>
</div>
)}
</div>
</div>
)}
{activeTab === 'maintenance' && (
<div className="space-y-3">
<p className="text-sm text-slate-500">{vd.maintenanceIntro}</p>
{ROUTINE_TYPES.map(({ key, intervalMonths }) => {
const latest = maintenanceLogs
.filter((l) => l.type === key)
.sort((a, b) => new Date(b.performedAt).getTime() - new Date(a.performedAt).getTime())[0]
return (
<MaintenanceRow
key={key}
vehicleId={params.id}
typeKey={key}
intervalMonths={intervalMonths}
currentMileage={vehicle.mileage}
log={latest}
onLogged={fetchMaintenance}
/>
)
})}
</div>
)}
{activeTab === 'calendar' && (
<VehicleCalendar vehicleId={params.id} />
)}
{activeTab === 'pricing' && (
<VehiclePricingManager vehicleId={params.id} />
)}
</div>
)
}
@@ -0,0 +1,806 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Plus, Edit, Eye, Search, Upload, X, DollarSign } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { formatCurrency } from '@rentaldrivego/types'
import { useDashboardI18n } from '@/components/I18nProvider'
interface Vehicle {
id: string
make: string
model: string
year: number
category: string
status: 'AVAILABLE' | 'RESERVED' | 'READY' | 'RENTED' | 'RETURNED' | 'NEEDS_CLEANING' | 'MAINTENANCE' | 'DAMAGE_REVIEW' | 'OUT_OF_SERVICE'
dailyRate: number
isPublished: boolean
primaryPhoto: string | null
licensePlate: string
vin?: string | null
pickupLocations?: string[]
allowDifferentDropoff?: boolean
dropoffLocations?: string[]
}
interface AddVehicleModalProps {
open: boolean
onClose: () => void
onSaved: () => void
}
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
const ALL_STATUSES: Vehicle['status'][] = [
'AVAILABLE', 'RESERVED', 'READY', 'RENTED',
'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE',
]
interface MaintenanceModalProps {
vehicleId: string
vehicleName: string
open: boolean
onClose: () => void
onSaved: () => void
}
function MaintenanceModal({ vehicleId, vehicleName, open, onClose, onSaved }: MaintenanceModalProps) {
const { dict } = useDashboardI18n()
const f = dict.fleet
const today = new Date().toISOString().slice(0, 10)
const [form, setForm] = useState({ type: '', description: '', cost: '', mileage: '', performedAt: today, nextDueAt: '' })
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const reset = () => {
setForm({ type: '', description: '', cost: '', mileage: '', performedAt: today, nextDueAt: '' })
setError(null)
}
const handleClose = () => { reset(); onClose() }
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.type) { setError(f.maintenanceTypeRequired); return }
setLoading(true)
setError(null)
try {
await apiFetch(`/vehicles/${vehicleId}/maintenance`, {
method: 'POST',
body: JSON.stringify({
type: form.type,
description: form.description || undefined,
cost: form.cost ? Math.round(parseFloat(form.cost) * 100) : undefined,
mileage: form.mileage ? parseInt(form.mileage) : undefined,
performedAt: new Date(form.performedAt).toISOString(),
nextDueAt: form.nextDueAt ? new Date(form.nextDueAt).toISOString() : undefined,
}),
})
onSaved()
handleClose()
} catch (err: any) {
setError(err.message ?? f.failedToSave)
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/40" onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-1">{f.logMaintenance}</h2>
<p className="text-sm text-slate-500 mb-5">{f.maintenanceSubtitle(vehicleName)}</p>
{error && <div className="px-3 py-2 mb-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.typeLabel}</label>
<input className="input-field" placeholder={f.typePlaceholder} value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })} autoFocus />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.description}</label>
<textarea className="input-field resize-none" rows={2} placeholder={f.descriptionPlaceholder} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.date}</label>
<input type="date" className="input-field" value={form.performedAt} onChange={(e) => setForm({ ...form, performedAt: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.nextDueDate}</label>
<input type="date" className="input-field" value={form.nextDueAt} onChange={(e) => setForm({ ...form, nextDueAt: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.cost}</label>
<input type="number" className="input-field" placeholder="0.00" min="0" step="0.01" value={form.cost} onChange={(e) => setForm({ ...form, cost: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.odometer}</label>
<input type="number" className="input-field" placeholder="e.g. 52000" min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
</div>
</div>
<div className="flex gap-3 pt-1">
<button type="button" onClick={handleClose} className="btn-secondary flex-1">{f.skip}</button>
<button type="submit" disabled={loading} className="btn-primary flex-1">{loading ? f.saving : f.saveLog}</button>
</div>
</form>
</div>
</div>
)
}
function StatusDropdown({ vehicleId, status, onStatusChange, disabled, disabledReason, onError }: {
vehicleId: string
status: Vehicle['status']
onStatusChange: (id: string, status: Vehicle['status']) => void
disabled?: boolean
disabledReason?: string
onError: (message: string) => void
}) {
const { dict } = useDashboardI18n()
const f = dict.fleet
const [saving, setSaving] = useState(false)
const select = async (next: Vehicle['status']) => {
if (next === status) return
setSaving(true)
try {
await apiFetch(`/vehicles/${vehicleId}/status`, { method: 'PATCH', body: JSON.stringify({ status: next }) })
onStatusChange(vehicleId, next)
} catch (err: any) {
onError(err.message ?? 'Failed to update vehicle status')
} finally {
setSaving(false)
}
}
return (
<select
value={status}
disabled={disabled || saving}
title={disabled ? disabledReason : undefined}
onChange={(e) => void select(e.target.value as Vehicle['status'])}
className={[
'rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 shadow-sm',
disabled || saving ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-slate-300',
].join(' ')}
>
{ALL_STATUSES.map((value) => (
<option key={value} value={value}>
{f.statusLabels[value]}
</option>
))}
</select>
)
}
const PRESET_COLORS = ['White', 'Black', 'Silver', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Brown', 'Beige', 'Gold']
const PRESET_MAKES = ['Audi', 'BMW', 'Chevrolet', 'Citroën', 'Dacia', 'Fiat', 'Ford', 'Honda', 'Hyundai', 'Kia', 'Mazda', 'Mercedes-Benz', 'Nissan', 'Opel', 'Peugeot', 'Renault', 'Seat', 'Skoda', 'Suzuki', 'Toyota', 'Volkswagen', 'Volvo']
const MODELS_BY_MAKE: Record<string, string[]> = {
'Audi': ['A1', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'Q2', 'Q3', 'Q5', 'Q7', 'Q8', 'TT'],
'BMW': ['116i', '118i', '120i', '218i', '316i', '318i', '320i', '325i', 'X1', 'X2', 'X3', 'X4', 'X5', 'X6', 'X7'],
'Chevrolet': ['Aveo', 'Captiva', 'Cruze', 'Malibu', 'Spark', 'Trax'],
'Citroën': ['Berlingo', 'C1', 'C3', 'C3 Aircross', 'C4', 'C4 Cactus', 'C5', 'C5 Aircross', 'Jumpy', 'SpaceTourer'],
'Dacia': ['Dokker', 'Duster', 'Jogger', 'Lodgy', 'Logan', 'Sandero', 'Spring'],
'Fiat': ['500', '500X', 'Bravo', 'Doblo', 'Ducato', 'Panda', 'Punto', 'Tipo'],
'Ford': ['B-Max', 'C-Max', 'EcoSport', 'Edge', 'Explorer', 'Fiesta', 'Focus', 'Fusion', 'Kuga', 'Mondeo', 'Mustang', 'Puma', 'Ranger', 'S-Max', 'Transit'],
'Honda': ['Accord', 'Civic', 'CR-V', 'HR-V', 'Jazz', 'Pilot'],
'Hyundai': ['Accent', 'Creta', 'Elantra', 'i10', 'i20', 'i30', 'ix35', 'Kona', 'Santa Fe', 'Sonata', 'Tucson'],
'Kia': ['Carnival', 'Ceed', 'Niro', 'Optima', 'Picanto', 'Rio', 'Sorento', 'Soul', 'Sportage', 'Stinger'],
'Mazda': ['2', '3', '6', 'CX-3', 'CX-5', 'CX-9', 'MX-5'],
'Mercedes-Benz': ['A-Class', 'B-Class', 'C-Class', 'CLA', 'E-Class', 'GLA', 'GLB', 'GLC', 'GLE', 'GLS', 'S-Class', 'Sprinter', 'Vito'],
'Nissan': ['Juke', 'Leaf', 'Micra', 'Murano', 'Navara', 'Note', 'Pathfinder', 'Patrol', 'Pulsar', 'Qashqai', 'X-Trail'],
'Opel': ['Astra', 'Corsa', 'Crossland', 'Grandland', 'Insignia', 'Meriva', 'Mokka', 'Zafira'],
'Peugeot': ['108', '208', '2008', '3008', '308', '4008', '408', '5008', '508', 'Boxer', 'Expert', 'Partner', 'Traveller'],
'Renault': ['Arkana', 'Captur', 'Clio', 'Duster', 'Espace', 'Grand Scenic', 'Kadjar', 'Kangoo', 'Koleos', 'Laguna', 'Master', 'Megane', 'Scenic', 'Talisman', 'Trafic', 'Twingo', 'Zoe'],
'Seat': ['Arona', 'Arosa', 'Ateca', 'Ibiza', 'Leon', 'Tarraco', 'Toledo'],
'Skoda': ['Fabia', 'Karoq', 'Kodiaq', 'Octavia', 'Rapid', 'Scala', 'Superb', 'Yeti'],
'Suzuki': ['Alto', 'Baleno', 'Celerio', 'Ignis', 'Jimny', 'S-Cross', 'Swift', 'Vitara'],
'Toyota': ['Auris', 'Avensis', 'Aygo', 'Camry', 'Corolla', 'C-HR', 'Fortuner', 'Hilux', 'Land Cruiser', 'Prius', 'RAV4', 'Rush', 'Urban Cruiser', 'Yaris'],
'Volkswagen': ['Amarok', 'Arteon', 'Caddy', 'Golf', 'ID.3', 'ID.4', 'Passat', 'Polo', 'Sharan', 'T-Cross', 'T-Roc', 'Tiguan', 'Touareg', 'Touran', 'Transporter', 'Up'],
'Volvo': ['S60', 'S90', 'V40', 'V60', 'V90', 'XC40', 'XC60', 'XC90'],
}
function parseLocationInput(value: string) {
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
}
function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
const { dict } = useDashboardI18n()
const f = dict.fleet
const [form, setForm] = useState({
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
licensePlate: '', vin: '', color: '', seats: 5, transmission: 'MANUAL',
fuelType: 'GASOLINE', mileage: '', pickupLocations: '', allowDifferentDropoff: false, dropoffLocations: '',
})
const [colorSelect, setColorSelect] = useState('')
const [makeSelect, setMakeSelect] = useState('')
const [modelSelect, setModelSelect] = useState('')
const [photoFiles, setPhotoFiles] = useState<File[]>([])
const [photoPreviews, setPhotoPreviews] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
const combined = [...photoFiles, ...files].slice(0, 10)
setPhotoFiles(combined)
setPhotoPreviews(combined.map((file) => URL.createObjectURL(file)))
e.target.value = ''
}
const removePhoto = (index: number) => {
URL.revokeObjectURL(photoPreviews[index])
const files = photoFiles.filter((_, i) => i !== index)
const previews = photoPreviews.filter((_, i) => i !== index)
setPhotoFiles(files)
setPhotoPreviews(previews)
}
const reset = () => {
setForm({
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
licensePlate: '', vin: '', color: '', seats: 5, transmission: 'MANUAL',
fuelType: 'GASOLINE', mileage: '', pickupLocations: '', allowDifferentDropoff: false, dropoffLocations: '',
})
setColorSelect('')
setMakeSelect('')
setModelSelect('')
photoPreviews.forEach((p) => URL.revokeObjectURL(p))
setPhotoFiles([])
setPhotoPreviews([])
setError(null)
}
const handleClose = () => { reset(); onClose() }
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.make) { setError(f.makeMissing); return }
if (!form.model) { setError(f.modelMissing); return }
if (!form.licensePlate) { setError(f.plateMissing); return }
if (form.allowDifferentDropoff && parseLocationInput(form.dropoffLocations).length === 0) {
setError(f.dropoffLocationsRequired)
return
}
setLoading(true)
setError(null)
try {
const vehicle = await apiFetch<{ id: string }>('/vehicles', {
method: 'POST',
body: JSON.stringify({
...form,
year: Number(form.year),
seats: Number(form.seats),
vin: form.vin.trim() || undefined,
mileage: form.mileage ? Number(form.mileage) : undefined,
pickupLocations: parseLocationInput(form.pickupLocations),
allowDifferentDropoff: form.allowDifferentDropoff,
dropoffLocations: form.allowDifferentDropoff ? parseLocationInput(form.dropoffLocations) : [],
}),
})
if (photoFiles.length > 0) {
const fd = new FormData()
photoFiles.forEach((file) => fd.append('photos', file))
await apiFetch(`/vehicles/${vehicle.id}/photos`, { method: 'POST', body: fd })
}
onSaved()
handleClose()
} catch (err: any) {
setError(err.message ?? f.failedToAdd)
} finally {
setLoading(false)
}
}
if (!open) return null
const presetModels = form.make && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/40" onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6 max-h-[90vh] overflow-y-auto">
<h2 className="text-lg font-semibold text-slate-900 mb-5">{f.addNewVehicle}</h2>
{error && (
<div className="px-3 py-2 mb-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Make & Model */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.make}</label>
<select
className="input-field"
value={makeSelect}
onChange={(e) => {
setMakeSelect(e.target.value)
setModelSelect('')
if (e.target.value !== 'custom') setForm({ ...form, make: e.target.value, model: '' })
else setForm({ ...form, make: '', model: '' })
}}
>
<option value="">{f.selectMake}</option>
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">{f.other}</option>
</select>
{makeSelect === 'custom' && (
<input className="input-field mt-2" placeholder={f.typeMake} required value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} autoFocus />
)}
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.model}</label>
{!presetModels ? (
<input className="input-field" placeholder={f.typeModel} required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
) : (
<>
<select
className="input-field"
value={modelSelect}
onChange={(e) => {
setModelSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, model: e.target.value })
else setForm({ ...form, model: '' })
}}
>
<option value="">{f.selectModel}</option>
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">{f.other}</option>
</select>
{modelSelect === 'custom' && (
<input className="input-field mt-2" placeholder={f.typeModel} required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} autoFocus />
)}
</>
)}
</div>
</div>
{/* Year & Category */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.year}</label>
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} required value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.category}</label>
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
<option key={c} value={c}>{f.categoryLabels[c]}</option>
))}
</select>
</div>
</div>
{/* License Plate & VIN */}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.licensePlate}</label>
<input className="input-field" placeholder="AB-1234-CD" required value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.vin}</label>
<input className="input-field" placeholder="1HGCM82633A123456" value={form.vin} onChange={(e) => setForm({ ...form, vin: e.target.value })} />
</div>
</div>
{/* Color, Seats, Transmission */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.color}</label>
<select
className="input-field"
value={colorSelect}
onChange={(e) => {
setColorSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value })
else setForm({ ...form, color: '' })
}}
>
<option value="">{f.select}</option>
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
<option value="custom">{f.other}</option>
</select>
{colorSelect === 'custom' && (
<input className="input-field mt-2" placeholder={f.typeColor} value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} autoFocus />
)}
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.seats}</label>
<input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.transmission}</label>
<select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}>
<option value="MANUAL">{f.manual}</option>
<option value="AUTOMATIC">{f.automatic}</option>
</select>
</div>
</div>
{/* Fuel Type & Odometer */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.fuelType}</label>
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
{(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const).map((fuel) => (
<option key={fuel} value={fuel}>{f.fuelTypeLabels[fuel]}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.odometer}</label>
<input
type="number"
className="input-field"
placeholder="e.g. 45000"
min="0"
value={form.mileage}
onChange={(e) => setForm({ ...form, mileage: e.target.value })}
/>
</div>
</div>
<div className="space-y-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.pickupLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
placeholder={f.pickupLocationsPlaceholder}
value={form.pickupLocations}
onChange={(e) => setForm({ ...form, pickupLocations: e.target.value })}
/>
<p className="mt-1 text-xs text-slate-400">{f.locationHint}</p>
</div>
<label className="flex items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm font-medium text-slate-700">
<input
type="checkbox"
checked={form.allowDifferentDropoff}
onChange={(e) => setForm({
...form,
allowDifferentDropoff: e.target.checked,
dropoffLocations: e.target.checked ? form.dropoffLocations : '',
})}
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<span>{f.allowDifferentDropoff}</span>
</label>
{form.allowDifferentDropoff && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.dropoffLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
placeholder={f.dropoffLocationsPlaceholder}
value={form.dropoffLocations}
onChange={(e) => setForm({ ...form, dropoffLocations: e.target.value })}
/>
<p className="mt-1 text-xs text-slate-400">{f.locationHint}</p>
</div>
)}
</div>
{/* Photos */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.photosLabel}</label>
{photoPreviews.length > 0 && (
<div className="grid grid-cols-3 gap-2 mb-3">
{photoPreviews.map((src, i) => (
<div key={i} className="relative h-24 rounded-lg overflow-hidden bg-slate-100 group">
<Image src={src} alt={`preview ${i + 1}`} fill className="object-cover" unoptimized />
<button
type="button"
onClick={() => removePhoto(i)}
className="absolute top-1 right-1 p-0.5 bg-blue-950/60 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
{photoFiles.length < 10 && (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handlePhotoChange}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
>
<Upload className="w-4 h-4" />
{photoFiles.length === 0 ? f.clickToAddPhotos : f.addMorePhotos}
</button>
</>
)}
</div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={handleClose} className="btn-secondary flex-1">{f.cancel}</button>
<button type="submit" disabled={loading} className="btn-primary flex-1">
{loading ? f.adding : f.addVehicle}
</button>
</div>
</form>
</div>
</div>
)
}
export default function FleetPage() {
const { dict, language } = useDashboardI18n()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const f = dict.fleet
const managerRequiredMessage =
language === 'fr'
? 'Cette action nécessite le rôle Gestionnaire ou supérieur.'
: language === 'ar'
? 'يتطلب هذا الإجراء دور المدير أو أعلى.'
: 'This action requires the Manager role or higher.'
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [employeeRole, setEmployeeRole] = useState<EmployeeRole | null>(null)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [publishedFilter, setPublishedFilter] = useState('')
const [maintenanceModal, setMaintenanceModal] = useState<{ vehicleId: string; vehicleName: string } | null>(null)
const fetchVehicles = () => {
setLoading(true)
apiFetch<Vehicle[]>('/vehicles?pageSize=100')
.then((result) => setVehicles(result ?? []))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => { fetchVehicles() }, [])
useEffect(() => {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as { role?: EmployeeRole }
if (profile.role) setEmployeeRole(profile.role)
} catch {}
}
let cancelled = false
apiFetch<{ employee: { role: EmployeeRole } }>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
setEmployeeRole(employee.role)
})
.catch(() => {})
return () => { cancelled = true }
}, [])
useEffect(() => {
if (searchParams.get('modal') === 'add-car') {
setShowAddModal(true)
}
}, [searchParams])
const canManageFleet = employeeRole === 'OWNER' || employeeRole === 'MANAGER'
function closeAddModal() {
setShowAddModal(false)
if (searchParams.get('modal') === 'add-car') {
const params = new URLSearchParams(searchParams.toString())
params.delete('modal')
router.replace(params.toString() ? `${pathname}?${params.toString()}` : pathname)
}
}
const togglePublished = async (id: string, current: boolean) => {
setActionError(null)
try {
await apiFetch(`/vehicles/${id}`, { method: 'PATCH', body: JSON.stringify({ isPublished: !current }) })
setVehicles((prev) => prev.map((v) => v.id === id ? { ...v, isPublished: !current } : v))
} catch (err: any) {
setActionError(err.statusCode === 403 ? managerRequiredMessage : (err.message ?? 'Failed to update publication status'))
}
}
const PUBLISHED = new Set<Vehicle['status']>(['AVAILABLE', 'RESERVED', 'READY', 'RENTED'])
const changeStatus = (id: string, status: Vehicle['status']) => {
const isPublished = PUBLISHED.has(status)
setVehicles((prev) => prev.map((v) => v.id === id ? { ...v, status, isPublished } : v))
setStatusFilter('')
if (status === 'MAINTENANCE') {
const vehicle = vehicles.find((v) => v.id === id)
if (vehicle) setMaintenanceModal({ vehicleId: id, vehicleName: `${vehicle.make} ${vehicle.model}` })
}
}
const filtered = vehicles.filter((v) => {
if (search && !`${v.make} ${v.model} ${v.licensePlate} ${v.vin ?? ''}`.toLowerCase().includes(search.toLowerCase())) return false
if (statusFilter && v.status !== statusFilter) return false
if (categoryFilter && v.category !== categoryFilter) return false
if (publishedFilter === 'published' && !v.isPublished) return false
if (publishedFilter === 'unpublished' && v.isPublished) return false
return true
})
return (
<div className="space-y-6">
{/* Header */}
<div className="rdg-page-heading flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">{f.heading}</h2>
<p className="text-sm text-slate-500 mt-0.5">{f.vehicleCount(vehicles.length)}</p>
</div>
<button onClick={() => setShowAddModal(true)} className="btn-primary" disabled={!canManageFleet} title={!canManageFleet ? managerRequiredMessage : undefined}>
<Plus className="w-4 h-4" /> {f.addVehicle}
</button>
</div>
{actionError ? <div className="card p-4 text-sm text-red-600">{actionError}</div> : null}
{/* Filters */}
<div className="card p-4 flex flex-wrap gap-3">
<div className="flex items-center gap-2 flex-1 min-w-48">
<Search className="w-4 h-4 text-slate-400" />
<input
className="flex-1 text-sm outline-none placeholder:text-slate-400"
placeholder={f.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select className="input-field w-36" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">{f.allStatuses}</option>
{ALL_STATUSES.map((s) => (
<option key={s} value={s}>{f.statusLabels[s]}</option>
))}
</select>
<select className="input-field w-36" value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)}>
<option value="">{f.allCategories}</option>
{(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
<option key={c} value={c}>{f.categoryLabels[c]}</option>
))}
</select>
<select className="input-field w-36" value={publishedFilter} onChange={(e) => setPublishedFilter(e.target.value)}>
<option value="">{f.allVisibility}</option>
<option value="published">{f.published}</option>
<option value="unpublished">{f.unpublished}</option>
</select>
</div>
{/* Table */}
<div className="card overflow-hidden">
{loading ? (
<div className="flex items-center justify-center h-48">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
) : error ? (
<div className="p-8 text-center">
<p className="text-red-600 font-medium">{error}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-100 bg-slate-50">
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colVehicle}</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colCategory}</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colStatus}</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colDailyRate}</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colPublished}</th>
<th className="text-end px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colActions}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((vehicle) => (
<tr key={vehicle.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4">
<div>
<p className="text-sm font-semibold text-slate-900">{vehicle.make} {vehicle.model}</p>
<p className="text-xs text-slate-400">
{vehicle.year} · {vehicle.licensePlate}
{vehicle.vin ? ` · ${vehicle.vin}` : ''}
</p>
</div>
</td>
<td className="px-6 py-4">
<span className="badge-gray">{f.categoryLabels[vehicle.category as keyof typeof f.categoryLabels] ?? vehicle.category}</span>
</td>
<td className="px-6 py-4">
<StatusDropdown
vehicleId={vehicle.id}
status={vehicle.status}
onStatusChange={changeStatus}
disabled={!canManageFleet}
disabledReason={managerRequiredMessage}
onError={setActionError}
/>
</td>
<td className="px-6 py-4 text-sm font-medium text-slate-900">
{formatCurrency(vehicle.dailyRate, 'MAD')}{f.perDay}
</td>
<td className="px-6 py-4">
<div className="flex items-center">
<button
onClick={() => togglePublished(vehicle.id, vehicle.isPublished)}
disabled={!canManageFleet}
title={!canManageFleet ? managerRequiredMessage : undefined}
className={[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none',
!canManageFleet ? 'cursor-not-allowed opacity-60' : '',
vehicle.isPublished ? 'bg-blue-600' : 'bg-slate-200',
].join(' ')}
>
<span
className={[
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition duration-200',
vehicle.isPublished ? 'ltr:translate-x-5 rtl:-translate-x-5' : 'translate-x-0',
].join(' ')}
/>
</button>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2 rtl:justify-start">
<Link href={`/fleet/${vehicle.id}?tab=pricing`} title="Manage pricing" aria-label="Manage pricing" className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<DollarSign className="w-4 h-4" />
</Link>
<Link href={`/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<Eye className="w-4 h-4" />
</Link>
<Link href={`/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<Edit className="w-4 h-4" />
</Link>
</div>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-sm text-slate-400">
{vehicles.length === 0 ? f.noVehicles : f.noMatch}
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
<AddVehicleModal open={showAddModal} onClose={closeAddModal} onSaved={fetchVehicles} />
{maintenanceModal && (
<MaintenanceModal
vehicleId={maintenanceModal.vehicleId}
vehicleName={maintenanceModal.vehicleName}
open={true}
onClose={() => setMaintenanceModal(null)}
onSaved={() => setMaintenanceModal(null)}
/>
)}
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
import DashboardAccessGuard from '@/components/layout/DashboardAccessGuard'
import Sidebar from '@/components/layout/Sidebar'
import TopBar from '@/components/layout/TopBar'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="rdg-app-shell fleet-dashboard-shell flex min-h-screen transition-colors print:block print:min-h-0 print:bg-white">
<div className="print:hidden">
<Sidebar />
</div>
<div className="rdg-workspace ms-0 flex min-h-screen min-w-0 flex-1 flex-col lg:ms-[var(--rdg-sidebar-size)] print:ms-0 print:min-h-0 print:block">
<div className="print:hidden">
<TopBar />
</div>
<main className="rdg-app-main flex-1 overflow-y-auto print:overflow-visible print:p-0">
<div className="rdg-app-content">
<DashboardAccessGuard>{children}</DashboardAccessGuard>
</div>
</main>
</div>
</div>
)
}
@@ -0,0 +1,469 @@
'use client'
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type NotificationItem = {
id: string
type: string
title: string
body: string
channel: string
status: string
sentAt: string | null
readAt: string | null
createdAt: string
providerMessageId: string | null
locale: string
}
type PreferenceItem = {
notificationType: string
channel: string
enabled: boolean
}
const COMPANY_EVENTS = [
'NEW_RESERVATION',
'RESERVATION_CANCELLED',
'PAYMENT_RECEIVED',
'SUBSCRIPTION_TRIAL_ENDING',
'MAINTENANCE_DUE',
'OFFER_EXPIRING',
]
const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
const CHANNEL_BADGE: Record<string, string> = {
EMAIL: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
SMS: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
WHATSAPP: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
IN_APP: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
PUSH: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
}
const STATUS_BADGE: Record<string, string> = {
PENDING: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
SENT: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
DELIVERED: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
FAILED: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
READ: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
}
export default function DashboardNotificationsPage() {
const { language } = useDashboardI18n()
const [notifications, setNotifications] = useState<NotificationItem[]>([])
const [history, setHistory] = useState<NotificationItem[]>([])
const [preferences, setPreferences] = useState<Record<string, boolean>>({})
const [activeTab, setActiveTab] = useState<'inbox' | 'history' | 'preferences'>('inbox')
const [loading, setLoading] = useState(true)
const [historyLoading, setHistoryLoading] = useState(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [filterChannel, setFilterChannel] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const copy = {
en: {
title: 'Notifications',
subtitle: 'Track in-app alerts, audit delivery history, and control preferences for your team account.',
inbox: 'Inbox',
history: 'History',
preferences: 'Preferences',
markAllRead: 'Mark all as read',
loading: 'Loading notifications…',
empty: 'No notifications yet.',
read: 'Read',
markRead: 'Mark as read',
event: 'Event',
channel: 'Channel',
status: 'Status',
sentAt: 'Sent at',
date: 'Date',
allChannels: 'All channels',
allStatuses: 'All statuses',
savePreferences: 'Save preferences',
saving: 'Saving…',
failedLoad: 'Failed to load notifications',
failedSave: 'Failed to save preferences',
noHistory: 'No notification history found.',
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'In-app', PUSH: 'Push' } as Record<string, string>,
statuses: { PENDING: 'Pending', SENT: 'Sent', DELIVERED: 'Delivered', FAILED: 'Failed', READ: 'Read' } as Record<string, string>,
events: {
NEW_RESERVATION: 'New reservation',
RESERVATION_CANCELLED: 'Reservation cancelled',
PAYMENT_RECEIVED: 'Payment received',
SUBSCRIPTION_TRIAL_ENDING: 'Trial ending',
MAINTENANCE_DUE: 'Maintenance due',
OFFER_EXPIRING: 'Offer expiring',
BOOKING_CONFIRMED: 'Booking confirmed',
VEHICLE_MAINTENANCE_DUE: 'Vehicle maintenance due',
} as Record<string, string>,
},
fr: {
title: 'Notifications',
subtitle: 'Suivez les alertes, auditez l\'historique de diffusion et gérez les préférences de votre équipe.',
inbox: 'Boîte de réception',
history: 'Historique',
preferences: 'Préférences',
markAllRead: 'Tout marquer comme lu',
loading: 'Chargement des notifications…',
empty: 'Aucune notification pour le moment.',
read: 'Lu',
markRead: 'Marquer comme lu',
event: 'Événement',
channel: 'Canal',
status: 'Statut',
sentAt: 'Envoyé le',
date: 'Date',
allChannels: 'Tous les canaux',
allStatuses: 'Tous les statuts',
savePreferences: 'Enregistrer les préférences',
saving: 'Enregistrement…',
failedLoad: 'Échec du chargement des notifications',
failedSave: 'Échec de l\'enregistrement des préférences',
noHistory: 'Aucun historique de notification trouvé.',
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans l\'application', PUSH: 'Push' } as Record<string, string>,
statuses: { PENDING: 'En attente', SENT: 'Envoyé', DELIVERED: 'Délivré', FAILED: 'Échoué', READ: 'Lu' } as Record<string, string>,
events: {
NEW_RESERVATION: 'Nouvelle réservation',
RESERVATION_CANCELLED: 'Réservation annulée',
PAYMENT_RECEIVED: 'Paiement reçu',
SUBSCRIPTION_TRIAL_ENDING: 'Fin d\'essai proche',
MAINTENANCE_DUE: 'Maintenance due',
OFFER_EXPIRING: 'Offre expirante',
BOOKING_CONFIRMED: 'Réservation confirmée',
VEHICLE_MAINTENANCE_DUE: 'Maintenance véhicule due',
} as Record<string, string>,
},
ar: {
title: 'الإشعارات',
subtitle: 'تابع التنبيهات وراجع سجل الإرسال وتحكم في التفضيلات لحساب فريقك.',
inbox: 'صندوق الوارد',
history: 'السجل',
preferences: 'التفضيلات',
markAllRead: 'تحديد الكل كمقروء',
loading: 'جارٍ تحميل الإشعارات…',
empty: 'لا توجد إشعارات حالياً.',
read: 'مقروء',
markRead: 'تحديد كمقروء',
event: 'الحدث',
channel: 'القناة',
status: 'الحالة',
sentAt: 'وقت الإرسال',
date: 'التاريخ',
allChannels: 'جميع القنوات',
allStatuses: 'جميع الحالات',
savePreferences: 'حفظ التفضيلات',
saving: 'جارٍ الحفظ…',
failedLoad: 'فشل تحميل الإشعارات',
failedSave: 'فشل حفظ التفضيلات',
noHistory: 'لا يوجد سجل إشعارات.',
channels: { EMAIL: 'البريد', SMS: 'رسائل', WHATSAPP: 'واتساب', IN_APP: 'داخل التطبيق', PUSH: 'إشعار فوري' } as Record<string, string>,
statuses: { PENDING: 'قيد الانتظار', SENT: 'مرسل', DELIVERED: 'تم التسليم', FAILED: 'فشل', READ: 'مقروء' } as Record<string, string>,
events: {
NEW_RESERVATION: 'حجز جديد',
RESERVATION_CANCELLED: 'إلغاء حجز',
PAYMENT_RECEIVED: 'تم استلام الدفع',
SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة',
MAINTENANCE_DUE: 'صيانة مستحقة',
OFFER_EXPIRING: 'عرض على وشك الانتهاء',
BOOKING_CONFIRMED: 'تأكيد الحجز',
VEHICLE_MAINTENANCE_DUE: 'صيانة المركبة مستحقة',
} as Record<string, string>,
},
}[language]
async function load() {
setLoading(true)
setError(null)
try {
const [notificationData, preferenceData] = await Promise.all([
apiFetch<NotificationItem[]>('/notifications/company'),
apiFetch<PreferenceItem[]>('/notifications/company/preferences'),
])
setNotifications(notificationData)
const mapped = Object.fromEntries(
preferenceData.map((item) => [`${item.notificationType}:${item.channel}`, item.enabled]),
)
setPreferences(mapped)
} catch (err: any) {
setError(err.message ?? copy.failedLoad)
} finally {
setLoading(false)
}
}
async function loadHistory() {
setHistoryLoading(true)
setError(null)
try {
const params = new URLSearchParams()
if (filterChannel) params.set('channel', filterChannel)
if (filterStatus) params.set('status', filterStatus)
const qs = params.toString()
const data = await apiFetch<NotificationItem[]>(`/notifications/history${qs ? `?${qs}` : ''}`)
setHistory(data)
setHistoryLoaded(true)
} catch (err: any) {
setError(err.message ?? copy.failedLoad)
} finally {
setHistoryLoading(false)
}
}
useEffect(() => {
load()
}, [])
useEffect(() => {
if (activeTab === 'history') {
loadHistory()
}
}, [activeTab, filterChannel, filterStatus])
async function markRead(id: string) {
await apiFetch(`/notifications/company/${id}/read`, { method: 'POST' })
setNotifications((current) =>
current.map((item) => (item.id === id ? { ...item, readAt: new Date().toISOString() } : item)),
)
window.dispatchEvent(new Event('notifications:updated'))
}
async function markAllRead() {
await apiFetch('/notifications/company/read-all', { method: 'POST' })
setNotifications((current) => current.map((item) => ({ ...item, readAt: item.readAt ?? new Date().toISOString() })))
window.dispatchEvent(new Event('notifications:updated'))
}
async function savePreferences() {
setSaving(true)
setError(null)
try {
const body = Object.entries(preferences).map(([key, enabled]) => {
const [notificationType, channel] = key.split(':')
return { notificationType, channel, enabled }
})
await apiFetch('/notifications/company/preferences', {
method: 'PATCH',
body: JSON.stringify(body),
})
} catch (err: any) {
setError(err.message ?? copy.failedSave)
} finally {
setSaving(false)
}
}
function preferenceValue(notificationType: string, channel: string) {
return preferences[`${notificationType}:${channel}`] ?? true
}
function setPreference(notificationType: string, channel: string, enabled: boolean) {
setPreferences((current) => ({
...current,
[`${notificationType}:${channel}`]: enabled,
}))
}
function labelEvent(type: string) {
return copy.events[type] ?? type.replaceAll('_', ' ')
}
return (
<div className="space-y-6">
<div className="rdg-page-heading flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">{copy.title}</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{copy.subtitle}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setActiveTab('inbox')}
className={activeTab === 'inbox' ? 'btn-primary' : 'btn-secondary'}
>
{copy.inbox}
</button>
<button
type="button"
onClick={() => setActiveTab('history')}
className={activeTab === 'history' ? 'btn-primary' : 'btn-secondary'}
>
{copy.history}
</button>
<button
type="button"
onClick={() => setActiveTab('preferences')}
className={activeTab === 'preferences' ? 'btn-primary' : 'btn-secondary'}
>
{copy.preferences}
</button>
</div>
</div>
{error ? <div className="card p-4 text-sm text-red-600 dark:text-red-400">{error}</div> : null}
{/* ── Inbox ── */}
{activeTab === 'inbox' ? (
<div className="space-y-4">
<div className="flex justify-end">
<button type="button" onClick={markAllRead} className="btn-secondary">
{copy.markAllRead}
</button>
</div>
{loading ? <div className="card p-6 text-sm text-slate-500">{copy.loading}</div> : null}
{!loading && notifications.length === 0 ? <div className="card p-6 text-sm text-slate-500">{copy.empty}</div> : null}
{!loading
? notifications.map((notification) => (
<article key={notification.id} className="card p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{labelEvent(notification.type)}
</p>
<h2 className="mt-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{notification.title}</h2>
</div>
{notification.readAt ? (
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-400">{copy.read}</span>
) : (
<button type="button" onClick={() => markRead(notification.id)} className="btn-secondary">
{copy.markRead}
</button>
)}
</div>
<p className="mt-3 text-sm leading-7 text-slate-600 dark:text-slate-300">{notification.body}</p>
<p className="mt-4 text-xs text-slate-400">{new Date(notification.createdAt).toLocaleString()}</p>
</article>
))
: null}
</div>
) : null}
{/* ── History ── */}
{activeTab === 'history' ? (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<select
value={filterChannel}
onChange={(e) => setFilterChannel(e.target.value)}
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-900 dark:bg-blue-950 dark:text-slate-200"
>
<option value="">{copy.allChannels}</option>
{COMPANY_CHANNELS.map((ch) => (
<option key={ch} value={ch}>{copy.channels[ch] ?? ch}</option>
))}
</select>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-900 dark:bg-blue-950 dark:text-slate-200"
>
<option value="">{copy.allStatuses}</option>
{Object.keys(copy.statuses).map((s) => (
<option key={s} value={s}>{copy.statuses[s]}</option>
))}
</select>
</div>
{historyLoading ? (
<div className="card p-6 text-sm text-slate-500">{copy.loading}</div>
) : historyLoaded && history.length === 0 ? (
<div className="card p-6 text-sm text-slate-500">{copy.noHistory}</div>
) : (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 dark:bg-blue-950">
<tr>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.date}</th>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.event}</th>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.channel}</th>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.title ?? 'Title'}</th>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.status}</th>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.sentAt}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-blue-900/50">
{history.map((item) => (
<tr key={item.id} className="hover:bg-slate-50/60 dark:hover:bg-blue-900/20">
<td className="whitespace-nowrap px-4 py-3 text-xs text-slate-500 dark:text-slate-400">
{new Date(item.createdAt).toLocaleString()}
</td>
<td className="whitespace-nowrap px-4 py-3 text-xs font-medium text-slate-700 dark:text-slate-300">
{labelEvent(item.type)}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${CHANNEL_BADGE[item.channel] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.channels[item.channel] ?? item.channel}
</span>
</td>
<td className="max-w-[260px] px-4 py-3">
<p className="truncate text-sm font-medium text-slate-800 dark:text-slate-200">{item.title}</p>
<p className="truncate text-xs text-slate-400 dark:text-slate-500">{item.body}</p>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${STATUS_BADGE[item.status] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.statuses[item.status] ?? item.status}
</span>
</td>
<td className="whitespace-nowrap px-4 py-3 text-xs text-slate-500 dark:text-slate-400">
{item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
) : null}
{/* ── Preferences ── */}
{activeTab === 'preferences' ? (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 dark:bg-blue-950">
<tr>
<th className="px-4 py-3 text-left font-semibold text-slate-600 dark:text-slate-300">{copy.event}</th>
{COMPANY_CHANNELS.map((channel) => (
<th key={channel} className="px-4 py-3 text-center font-semibold text-slate-600 dark:text-slate-300">
{copy.channels[channel] ?? channel.replace('_', ' ')}
</th>
))}
</tr>
</thead>
<tbody>
{COMPANY_EVENTS.map((eventName) => (
<tr key={eventName} className="border-t border-slate-100 dark:border-blue-900/50">
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-200">{copy.events[eventName] ?? eventName.replaceAll('_', ' ')}</td>
{COMPANY_CHANNELS.map((channel) => (
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
<input
type="checkbox"
checked={preferenceValue(eventName, channel)}
onChange={(event) => setPreference(eventName, channel, event.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-600"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-end border-t border-slate-100 dark:border-blue-900/50 p-4">
<button type="button" onClick={savePreferences} disabled={saving} className="btn-primary">
{saving ? copy.saving : copy.savePreferences}
</button>
</div>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,797 @@
'use client'
import { useEffect, useState } from 'react'
import dayjs from 'dayjs'
import { formatCurrency, getCurrencyLabel } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
const CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const
type Category = typeof CATEGORIES[number]
interface Offer {
id: string
title: string
description?: string | null
termsAndConds?: string | null
type: 'PERCENTAGE' | 'FIXED_AMOUNT' | 'FREE_DAY' | 'SPECIAL_RATE'
discountValue: number
specialRate?: number | null
appliesToAll: boolean
categories: Category[]
minRentalDays?: number | null
maxRentalDays?: number | null
promoCode?: string | null
maxRedemptions?: number | null
validFrom: string
validUntil: string
isActive: boolean
isPublic: boolean
isFeatured: boolean
redemptionCount?: number
vehicles?: Array<{ vehicleId: string }>
}
interface FleetVehicle {
id: string
make: string
model: string
year: number
category: string
licensePlate: string
}
interface FormState {
title: string
description: string
termsAndConds: string
type: Offer['type']
discountValue: string
specialRate: string
appliesToAll: boolean
categories: Category[]
vehicleIds: string[]
minRentalDays: string
maxRentalDays: string
promoCode: string
maxRedemptions: string
validFrom: string
validUntil: string
isActive: boolean
isPublic: boolean
isFeatured: boolean
}
function emptyForm(): FormState {
const today = dayjs().format('YYYY-MM-DD')
const nextMonth = dayjs().add(30, 'day').format('YYYY-MM-DD')
return {
title: '',
description: '',
termsAndConds: '',
type: 'PERCENTAGE',
discountValue: '',
specialRate: '',
appliesToAll: true,
categories: [],
vehicleIds: [],
minRentalDays: '',
maxRentalDays: '',
promoCode: '',
maxRedemptions: '',
validFrom: today,
validUntil: nextMonth,
isActive: true,
isPublic: true,
isFeatured: false,
}
}
function offerToForm(o: Offer): FormState {
return {
title: o.title,
description: o.description ?? '',
termsAndConds: o.termsAndConds ?? '',
type: o.type,
discountValue: String(o.discountValue),
specialRate: o.specialRate != null ? String(o.specialRate) : '',
appliesToAll: o.appliesToAll,
categories: o.categories ?? [],
vehicleIds: o.vehicles?.map((v) => v.vehicleId) ?? [],
minRentalDays: o.minRentalDays != null ? String(o.minRentalDays) : '',
maxRentalDays: o.maxRentalDays != null ? String(o.maxRentalDays) : '',
promoCode: o.promoCode ?? '',
maxRedemptions: o.maxRedemptions != null ? String(o.maxRedemptions) : '',
validFrom: dayjs(o.validFrom).format('YYYY-MM-DD'),
validUntil: dayjs(o.validUntil).format('YYYY-MM-DD'),
isActive: o.isActive,
isPublic: o.isPublic,
isFeatured: o.isFeatured,
}
}
const copy = {
en: {
title: 'Offers',
subtitle: 'Promotions shown on your site and optionally on the marketplace.',
createOffer: 'Create Offer',
editOffer: 'Edit Offer',
deleteOffer: 'Delete Offer',
confirmDelete: 'Are you sure you want to delete this offer? This action cannot be undone.',
cancel: 'Cancel',
save: 'Save',
saving: 'Saving…',
delete: 'Delete',
deleting: 'Deleting…',
ends: 'ends',
active: 'Active',
inactive: 'Inactive',
marketplace: 'Marketplace',
featured: 'Featured',
empty: 'No offers yet. Create your first offer to attract customers.',
labelTitle: 'Title *',
labelDescription: 'Description',
labelTerms: 'Terms & Conditions',
labelType: 'Discount Type *',
labelDiscount: 'Discount Value *',
labelSpecialRate: 'Special Rate (MAD/day) *',
labelAppliesToAll: 'Applies to all vehicles',
labelCategories: 'Vehicle Categories',
labelVehicles: 'Specific Vehicles',
labelVehicleSearch: 'Search vehicles…',
labelMinDays: 'Min Rental Days',
labelMaxDays: 'Max Rental Days',
labelPromoCode: 'Promo Code',
labelMaxRedemptions: 'Max Redemptions',
labelValidFrom: 'Valid From *',
labelValidUntil: 'Valid Until *',
labelIsActive: 'Active',
labelIsPublic: 'Show on marketplace',
labelIsFeatured: 'Featured',
typePERCENTAGE: 'Percentage (%)',
typeFIXED_AMOUNT: 'Fixed Amount (MAD)',
typeFREE_DAY: 'Free Day',
typeSPECIAL_RATE: 'Special Daily Rate',
placeholderTitle: 'e.g. Summer Promotion',
placeholderDescription: 'Optional description…',
placeholderTerms: 'Terms and conditions…',
placeholderPromoCode: 'e.g. SUMMER20',
errorTitle: 'Title is required.',
errorDiscount: 'Please enter a valid discount value.',
errorDates: 'Valid From and Valid Until are required.',
errorDateOrder: 'Valid Until must be after Valid From.',
failedSave: 'Failed to save offer.',
failedDelete: 'Failed to delete offer.',
redemptions: 'redemptions',
freeDay: 'Free day',
loadingVehicles: 'Loading fleet…',
noVehiclesFound: 'No vehicles found.',
selected: (n: number) => `${n} selected`,
categoryLabels: {
ECONOMY: 'Economy', COMPACT: 'Compact', MIDSIZE: 'Midsize', FULLSIZE: 'Full-size',
SUV: 'SUV', LUXURY: 'Luxury', VAN: 'Van', TRUCK: 'Truck',
},
},
fr: {
title: 'Offres',
subtitle: 'Promotions affichées sur votre site et éventuellement sur la marketplace.',
createOffer: 'Créer une offre',
editOffer: "Modifier l'offre",
deleteOffer: "Supprimer l'offre",
confirmDelete: 'Êtes-vous sûr de vouloir supprimer cette offre ? Cette action est irréversible.',
cancel: 'Annuler',
save: 'Enregistrer',
saving: 'Enregistrement…',
delete: 'Supprimer',
deleting: 'Suppression…',
ends: 'expire le',
active: 'Actif',
inactive: 'Inactif',
marketplace: 'Marketplace',
featured: 'Mise en avant',
empty: 'Aucune offre. Créez votre première offre pour attirer des clients.',
labelTitle: 'Titre *',
labelDescription: 'Description',
labelTerms: 'Conditions générales',
labelType: 'Type de remise *',
labelDiscount: 'Valeur de la remise *',
labelSpecialRate: 'Tarif spécial (MAD/jour) *',
labelAppliesToAll: 'Applicable à tous les véhicules',
labelCategories: 'Catégories de véhicules',
labelVehicles: 'Véhicules spécifiques',
labelVehicleSearch: 'Rechercher des véhicules…',
labelMinDays: 'Jours de location minimum',
labelMaxDays: 'Jours de location maximum',
labelPromoCode: 'Code promo',
labelMaxRedemptions: 'Nombre max de rédemptions',
labelValidFrom: 'Valable du *',
labelValidUntil: "Valable jusqu'au *",
labelIsActive: 'Actif',
labelIsPublic: 'Afficher sur la marketplace',
labelIsFeatured: 'Mise en avant',
typePERCENTAGE: 'Pourcentage (%)',
typeFIXED_AMOUNT: 'Montant fixe (MAD)',
typeFREE_DAY: 'Jour gratuit',
typeSPECIAL_RATE: 'Tarif journalier spécial',
placeholderTitle: 'ex. Promotion estivale',
placeholderDescription: 'Description optionnelle…',
placeholderTerms: 'Conditions générales…',
placeholderPromoCode: 'ex. ETE20',
errorTitle: 'Le titre est requis.',
errorDiscount: 'Veuillez saisir une valeur de remise valide.',
errorDates: 'Les dates de début et de fin sont requises.',
errorDateOrder: 'La date de fin doit être après la date de début.',
failedSave: "Échec de l'enregistrement de l'offre.",
failedDelete: "Échec de la suppression de l'offre.",
redemptions: 'rédemptions',
freeDay: 'Jour gratuit',
loadingVehicles: 'Chargement de la flotte…',
noVehiclesFound: 'Aucun véhicule trouvé.',
selected: (n: number) => `${n} sélectionné${n > 1 ? 's' : ''}`,
categoryLabels: {
ECONOMY: 'Économique', COMPACT: 'Compacte', MIDSIZE: 'Intermédiaire', FULLSIZE: 'Grande berline',
SUV: 'SUV', LUXURY: 'Luxe', VAN: 'Van', TRUCK: 'Camionnette',
},
},
ar: {
title: 'العروض',
subtitle: 'عروض ترويجية تظهر في موقعك ويمكن عرضها أيضاً في السوق.',
createOffer: 'إنشاء عرض',
editOffer: 'تعديل العرض',
deleteOffer: 'حذف العرض',
confirmDelete: 'هل أنت متأكد من حذف هذا العرض؟ لا يمكن التراجع عن هذا الإجراء.',
cancel: 'إلغاء',
save: 'حفظ',
saving: 'جارٍ الحفظ…',
delete: 'حذف',
deleting: 'جارٍ الحذف…',
ends: 'تنتهي في',
active: 'نشط',
inactive: 'غير نشط',
marketplace: 'السوق',
featured: 'مميز',
empty: 'لا توجد عروض بعد. أنشئ أول عرض لجذب العملاء.',
labelTitle: 'العنوان *',
labelDescription: 'الوصف',
labelTerms: 'الشروط والأحكام',
labelType: 'نوع الخصم *',
labelDiscount: 'قيمة الخصم *',
labelSpecialRate: 'السعر الخاص (درهم/يوم) *',
labelAppliesToAll: 'ينطبق على جميع المركبات',
labelCategories: 'فئات المركبات',
labelVehicles: 'مركبات محددة',
labelVehicleSearch: 'ابحث عن مركبة…',
labelMinDays: 'الحد الأدنى لأيام الإيجار',
labelMaxDays: 'الحد الأقصى لأيام الإيجار',
labelPromoCode: 'رمز الترويج',
labelMaxRedemptions: 'الحد الأقصى للاسترداد',
labelValidFrom: 'صالح من *',
labelValidUntil: 'صالح حتى *',
labelIsActive: 'نشط',
labelIsPublic: 'عرض في السوق',
labelIsFeatured: 'مميز',
typePERCENTAGE: 'نسبة مئوية (%)',
typeFIXED_AMOUNT: 'مبلغ ثابت (درهم)',
typeFREE_DAY: 'يوم مجاني',
typeSPECIAL_RATE: 'سعر يومي خاص',
placeholderTitle: 'مثال: عرض الصيف',
placeholderDescription: 'وصف اختياري…',
placeholderTerms: 'الشروط والأحكام…',
placeholderPromoCode: 'مثال: SUMMER20',
errorTitle: 'العنوان مطلوب.',
errorDiscount: 'يرجى إدخال قيمة خصم صحيحة.',
errorDates: 'تاريخ البداية والنهاية مطلوبان.',
errorDateOrder: 'يجب أن يكون تاريخ الانتهاء بعد تاريخ البداية.',
failedSave: 'فشل حفظ العرض.',
failedDelete: 'فشل حذف العرض.',
redemptions: 'استرداد',
freeDay: 'يوم مجاني',
loadingVehicles: 'جارٍ تحميل الأسطول…',
noVehiclesFound: 'لم يتم العثور على مركبات.',
selected: (n: number) => `${n} محدد`,
categoryLabels: {
ECONOMY: 'اقتصادية', COMPACT: 'مدمجة', MIDSIZE: 'متوسطة', FULLSIZE: 'كبيرة',
SUV: 'دفع رباعي', LUXURY: 'فاخرة', VAN: 'فان', TRUCK: 'شاحنة',
},
},
}
function discountLabel(offer: Offer, t: typeof copy['en'], language: 'en' | 'fr' | 'ar'): string {
if (offer.type === 'PERCENTAGE') return `${offer.discountValue}%`
if (offer.type === 'FIXED_AMOUNT') return formatCurrency(offer.discountValue, 'MAD', language)
if (offer.type === 'FREE_DAY') return t.freeDay
if (offer.type === 'SPECIAL_RATE') return `${offer.specialRate ?? offer.discountValue} ${getCurrencyLabel(language)}${language === 'fr' ? '/jour' : language === 'ar' ? '/يوم' : '/day'}`
return String(offer.discountValue)
}
export default function OffersPage() {
const { language } = useDashboardI18n()
const t = copy[language]
const [offers, setOffers] = useState<Offer[]>([])
const [error, setError] = useState<string | null>(null)
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<Offer | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const [formError, setFormError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<Offer | null>(null)
const [deleting, setDeleting] = useState(false)
const [fleet, setFleet] = useState<FleetVehicle[]>([])
const [loadingFleet, setLoadingFleet] = useState(false)
const [vehicleSearch, setVehicleSearch] = useState('')
function load() {
apiFetch<Offer[]>('/offers')
.then((data) => {
setOffers(Array.isArray(data) ? data : [])
setError(null)
})
.catch((err) => setError(err.message))
}
useEffect(() => { load() }, [])
async function loadFleet() {
setLoadingFleet(true)
try {
const data = await apiFetch<FleetVehicle[]>('/vehicles?pageSize=200')
setFleet(Array.isArray(data) ? data : [])
} catch {
// non-fatal; vehicle picker just stays empty
} finally {
setLoadingFleet(false)
}
}
function openCreate() {
setEditing(null)
setForm(emptyForm())
setFormError(null)
setVehicleSearch('')
setModalOpen(true)
loadFleet()
}
async function openEdit(offer: Offer) {
setEditing(offer)
setForm(offerToForm(offer))
setFormError(null)
setVehicleSearch('')
setModalOpen(true)
// Load fleet and the offer's current vehicle IDs in parallel
loadFleet()
try {
const detail = await apiFetch<Offer>(`/offers/${offer.id}`)
setForm((prev) => ({
...prev,
vehicleIds: Array.isArray(detail?.vehicles) ? detail.vehicles.map((v) => v.vehicleId) : [],
}))
} catch {
// leave vehicleIds as empty if fetch fails
}
}
function closeModal() {
setModalOpen(false)
setEditing(null)
setFormError(null)
}
function toggleCategory(cat: Category) {
setForm((f) => ({
...f,
categories: f.categories.includes(cat)
? f.categories.filter((c) => c !== cat)
: [...f.categories, cat],
}))
}
function toggleVehicle(id: string) {
setForm((f) => ({
...f,
vehicleIds: f.vehicleIds.includes(id)
? f.vehicleIds.filter((v) => v !== id)
: [...f.vehicleIds, id],
}))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setFormError(null)
if (!form.title.trim()) return setFormError(t.errorTitle)
if (!form.validFrom || !form.validUntil) return setFormError(t.errorDates)
if (form.validUntil <= form.validFrom) return setFormError(t.errorDateOrder)
if (form.type !== 'FREE_DAY') {
const dv = Number(form.discountValue)
if (isNaN(dv) || dv < 0) return setFormError(t.errorDiscount)
}
setSaving(true)
try {
const payload: Record<string, unknown> = {
title: form.title.trim(),
description: form.description.trim() || undefined,
termsAndConds: form.termsAndConds.trim() || undefined,
type: form.type,
discountValue: form.type === 'FREE_DAY' ? 0 : Number(form.discountValue),
specialRate: form.specialRate ? Number(form.specialRate) : undefined,
appliesToAll: form.appliesToAll,
categories: form.appliesToAll ? [] : form.categories,
minRentalDays: form.minRentalDays ? Number(form.minRentalDays) : undefined,
maxRentalDays: form.maxRentalDays ? Number(form.maxRentalDays) : undefined,
promoCode: form.promoCode.trim() || undefined,
maxRedemptions: form.maxRedemptions ? Number(form.maxRedemptions) : undefined,
validFrom: new Date(form.validFrom).toISOString(),
validUntil: new Date(form.validUntil).toISOString(),
isActive: form.isActive,
isPublic: form.isPublic,
isFeatured: form.isFeatured,
vehicleIds: form.appliesToAll ? [] : form.vehicleIds,
}
if (editing) {
await apiFetch(`/offers/${editing.id}`, { method: 'PATCH', body: JSON.stringify(payload) })
} else {
await apiFetch('/offers', { method: 'POST', body: JSON.stringify(payload) })
}
closeModal()
load()
} catch (err: any) {
setFormError(err.message ?? t.failedSave)
} finally {
setSaving(false)
}
}
async function handleDelete() {
if (!deleteTarget) return
setDeleting(true)
try {
await apiFetch(`/offers/${deleteTarget.id}`, { method: 'DELETE' })
setDeleteTarget(null)
load()
} catch (err: any) {
setFormError(err.message ?? t.failedDelete)
setDeleteTarget(null)
} finally {
setDeleting(false)
}
}
const isRtl = language === 'ar'
const filteredFleet = fleet.filter((v) => {
const q = vehicleSearch.toLowerCase()
return (
!q ||
v.make.toLowerCase().includes(q) ||
v.model.toLowerCase().includes(q) ||
v.licensePlate.toLowerCase().includes(q)
)
})
return (
<div className="space-y-6">
{/* Header */}
<div className="rdg-page-heading flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-slate-900">{t.title}</h2>
<p className="text-sm text-slate-500 mt-1">{t.subtitle}</p>
</div>
<button type="button" onClick={openCreate} className="btn-primary shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
{t.createOffer}
</button>
</div>
{error && <div className="card p-4 text-sm text-red-600">{error}</div>}
{/* Offer cards */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{offers.map((offer) => (
<div key={offer.id} className="card p-5 flex flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-base font-semibold text-slate-900 truncate">{offer.title}</h3>
<p className="text-sm text-slate-500 mt-0.5">
{t[`type${offer.type}` as keyof typeof t] as string} · {t.ends} {dayjs(offer.validUntil).format('MMM D, YYYY')}
</p>
</div>
<span className={`shrink-0 ${offer.isActive ? 'badge-green' : 'badge-gray'}`}>
{offer.isActive ? t.active : t.inactive}
</span>
</div>
<p className="text-2xl font-bold text-slate-900">{discountLabel(offer, t, language)}</p>
{offer.description && (
<p className="text-sm text-slate-500 line-clamp-2">{offer.description}</p>
)}
<div className="flex flex-wrap gap-2">
{offer.isPublic && <span className="badge-blue">{t.marketplace}</span>}
{offer.isFeatured && <span className="badge-purple">{t.featured}</span>}
{offer.promoCode && <span className="badge-amber">{offer.promoCode}</span>}
{typeof offer.redemptionCount === 'number' && (
<span className="badge-gray">{offer.redemptionCount} {t.redemptions}</span>
)}
</div>
<div className={`flex gap-2 mt-auto pt-2 border-t border-slate-100 ${isRtl ? 'flex-row-reverse' : ''}`}>
<button type="button" onClick={() => openEdit(offer)} className="btn-secondary text-xs px-3 py-1.5">
{t.editOffer}
</button>
<button type="button" onClick={() => setDeleteTarget(offer)} className="btn-danger text-xs px-3 py-1.5">
{t.deleteOffer}
</button>
</div>
</div>
))}
{offers.length === 0 && !error && (
<div className="col-span-full card p-10 text-center text-sm text-slate-400">{t.empty}</div>
)}
</div>
{/* Create / Edit Modal */}
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-blue-950/50 p-4">
<div className="relative w-full max-w-2xl my-8 card p-6" dir={isRtl ? 'rtl' : 'ltr'}>
<h3 className="text-lg font-semibold text-slate-900 mb-5">
{editing ? t.editOffer : t.createOffer}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Title */}
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelTitle}</label>
<input
className="input-field"
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
placeholder={t.placeholderTitle}
/>
</div>
{/* Description */}
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelDescription}</label>
<textarea
className="input-field resize-none"
rows={2}
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder={t.placeholderDescription}
/>
</div>
{/* Type + Discount/Rate */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelType}</label>
<select
className="input-field"
value={form.type}
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as Offer['type'] }))}
>
<option value="PERCENTAGE">{t.typePERCENTAGE}</option>
<option value="FIXED_AMOUNT">{t.typeFIXED_AMOUNT}</option>
<option value="FREE_DAY">{t.typeFREE_DAY}</option>
<option value="SPECIAL_RATE">{t.typeSPECIAL_RATE}</option>
</select>
</div>
<div>
{form.type === 'SPECIAL_RATE' ? (
<>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelSpecialRate}</label>
<input type="number" min={0} className="input-field" value={form.specialRate} onChange={(e) => setForm((f) => ({ ...f, specialRate: e.target.value }))} />
</>
) : form.type !== 'FREE_DAY' ? (
<>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelDiscount}</label>
<input type="number" min={0} className="input-field" value={form.discountValue} onChange={(e) => setForm((f) => ({ ...f, discountValue: e.target.value }))} />
</>
) : null}
</div>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelValidFrom}</label>
<input type="date" className="input-field" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelValidUntil}</label>
<input type="date" className="input-field" value={form.validUntil} onChange={(e) => setForm((f) => ({ ...f, validUntil: e.target.value }))} />
</div>
</div>
{/* Promo code + Max Redemptions */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelPromoCode}</label>
<input className="input-field" value={form.promoCode} onChange={(e) => setForm((f) => ({ ...f, promoCode: e.target.value }))} placeholder={t.placeholderPromoCode} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelMaxRedemptions}</label>
<input type="number" min={1} className="input-field" value={form.maxRedemptions} onChange={(e) => setForm((f) => ({ ...f, maxRedemptions: e.target.value }))} />
</div>
</div>
{/* Min / Max Rental Days */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelMinDays}</label>
<input type="number" min={1} className="input-field" value={form.minRentalDays} onChange={(e) => setForm((f) => ({ ...f, minRentalDays: e.target.value }))} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelMaxDays}</label>
<input type="number" min={1} className="input-field" value={form.maxRentalDays} onChange={(e) => setForm((f) => ({ ...f, maxRentalDays: e.target.value }))} />
</div>
</div>
{/* Applies-to-all toggle */}
<div>
<label className="flex items-center gap-2 text-sm text-slate-700 cursor-pointer select-none">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 accent-blue-600"
checked={form.appliesToAll}
onChange={(e) => setForm((f) => ({ ...f, appliesToAll: e.target.checked }))}
/>
{t.labelAppliesToAll}
</label>
</div>
{/* Scoped selection — only shown when not appliesToAll */}
{!form.appliesToAll && (
<div className="space-y-4 rounded-xl border border-slate-200 p-4">
{/* Category chips */}
<div>
<p className="text-xs font-medium text-slate-600 mb-2">{t.labelCategories}</p>
<div className="flex flex-wrap gap-2">
{CATEGORIES.map((cat) => (
<button
key={cat}
type="button"
onClick={() => toggleCategory(cat)}
className={`px-3 py-1 text-xs rounded-full border transition-colors ${
form.categories.includes(cat)
? 'bg-blue-600 text-white border-blue-600'
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{t.categoryLabels[cat]}
</button>
))}
</div>
</div>
{/* Vehicle picker */}
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-slate-600">{t.labelVehicles}</p>
{form.vehicleIds.length > 0 && (
<span className="badge-blue">{t.selected(form.vehicleIds.length)}</span>
)}
</div>
{/* Search input */}
<div className="relative mb-2">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-slate-400 pointer-events-none" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 111 11a6 6 0 0116 0z" />
</svg>
<input
className="input-field pl-8 text-xs py-1.5"
value={vehicleSearch}
onChange={(e) => setVehicleSearch(e.target.value)}
placeholder={t.labelVehicleSearch}
/>
</div>
{/* Vehicle list */}
<div className="max-h-52 overflow-y-auto rounded-lg border border-slate-200 divide-y divide-slate-100">
{loadingFleet ? (
<p className="px-4 py-6 text-center text-xs text-slate-400">{t.loadingVehicles}</p>
) : filteredFleet.length === 0 ? (
<p className="px-4 py-6 text-center text-xs text-slate-400">{t.noVehiclesFound}</p>
) : (
filteredFleet.map((v) => {
const checked = form.vehicleIds.includes(v.id)
return (
<label
key={v.id}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
checked ? 'bg-blue-50' : 'hover:bg-slate-50'
}`}
>
<input
type="checkbox"
className="h-4 w-4 shrink-0 rounded border-slate-300 accent-blue-600"
checked={checked}
onChange={() => toggleVehicle(v.id)}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-800 truncate">
{v.year} {v.make} {v.model}
</p>
<p className="text-xs text-slate-400">{v.licensePlate} · {t.categoryLabels[v.category as Category] ?? v.category}</p>
</div>
</label>
)
})
)}
</div>
</div>
</div>
)}
{/* Terms */}
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelTerms}</label>
<textarea
className="input-field resize-none"
rows={2}
value={form.termsAndConds}
onChange={(e) => setForm((f) => ({ ...f, termsAndConds: e.target.value }))}
placeholder={t.placeholderTerms}
/>
</div>
{/* Toggles */}
<div className="flex flex-wrap gap-x-6 gap-y-2">
{([
{ key: 'isActive', label: t.labelIsActive },
{ key: 'isPublic', label: t.labelIsPublic },
{ key: 'isFeatured', label: t.labelIsFeatured },
] as const).map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 text-sm text-slate-700 cursor-pointer select-none">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 accent-blue-600"
checked={form[key] as boolean}
onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.checked }))}
/>
{label}
</label>
))}
</div>
{formError && <p className="text-sm text-red-600">{formError}</p>}
<div className={`flex gap-3 pt-2 ${isRtl ? 'flex-row-reverse' : 'justify-end'}`}>
<button type="button" onClick={closeModal} className="btn-secondary">{t.cancel}</button>
<button type="submit" disabled={saving} className="btn-primary">
{saving ? t.saving : t.save}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete confirmation */}
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/50 p-4">
<div className="card p-6 w-full max-w-sm" dir={isRtl ? 'rtl' : 'ltr'}>
<h3 className="text-base font-semibold text-slate-900 mb-2">{t.deleteOffer}</h3>
<p className="text-sm text-slate-600 mb-5">{t.confirmDelete}</p>
{formError && <p className="text-sm text-red-600 mb-3">{formError}</p>}
<div className={`flex gap-3 ${isRtl ? 'flex-row-reverse' : 'justify-end'}`}>
<button type="button" onClick={() => setDeleteTarget(null)} className="btn-secondary">{t.cancel}</button>
<button type="button" onClick={handleDelete} disabled={deleting} className="btn-danger">
{deleting ? t.deleting : t.delete}
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,366 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import dayjs from 'dayjs'
import Link from 'next/link'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { Globe, CheckCircle, XCircle, Clock, ChevronRight, User, Car, CalendarDays, MessageSquare } from 'lucide-react'
import { useDashboardI18n } from '@/components/I18nProvider'
interface OnlineReservation {
id: string
status: 'DRAFT' | 'CONFIRMED' | 'CANCELLED' | 'ACTIVE' | 'COMPLETED' | 'NO_SHOW'
source: string
startDate: string
endDate: string
totalAmount: number
totalDays: number
dailyRate: number
notes: string | null
cancelReason: string | null
createdAt: string
vehicle: { make: string; model: string; year: number; licensePlate: string }
customer: { firstName: string; lastName: string; email: string; phone: string | null }
bookingRequest: {
stage: string
documentsRequired: boolean
paymentRequired: boolean
documentsMissing: string[]
}
}
const STATUS_STYLE: Record<string, string> = {
DRAFT: 'bg-orange-100 text-orange-800',
CONFIRMED: 'bg-emerald-100 text-emerald-700',
CANCELLED: 'bg-rose-100 text-rose-700',
ACTIVE: 'bg-blue-100 text-blue-700',
COMPLETED: 'bg-slate-100 text-slate-600',
NO_SHOW: 'bg-orange-100 text-orange-700',
}
const STATUS_LABEL: Record<string, string> = {
DRAFT: 'Pending',
CONFIRMED: 'Confirmed',
CANCELLED: 'Declined',
ACTIVE: 'Active',
COMPLETED: 'Completed',
NO_SHOW: 'No show',
}
const REQUEST_STAGE_STYLE: Record<string, string> = {
REQUEST_SENT: 'bg-orange-100 text-orange-800',
COMPANY_CONFIRMED: 'bg-blue-100 text-blue-800',
DOCUMENTS_REQUIRED: 'bg-amber-100 text-amber-800',
PAYMENT_REQUIRED: 'bg-purple-100 text-purple-800',
BOOKING_CONFIRMED: 'bg-emerald-100 text-emerald-700',
CANCELLED: 'bg-rose-100 text-rose-700',
}
const REQUEST_STAGE_LABEL: Record<string, string> = {
REQUEST_SENT: 'Waiting for company review',
COMPANY_CONFIRMED: 'Availability confirmed',
DOCUMENTS_REQUIRED: 'Driver documents missing',
PAYMENT_REQUIRED: 'Payment or deposit pending',
BOOKING_CONFIRMED: 'Ready to proceed',
CANCELLED: 'Request closed',
}
const MISSING_ITEM_LABEL: Record<string, string> = {
DATE_OF_BIRTH: 'Date of birth',
IDENTITY_DOCUMENT: 'Identity document',
FULL_ADDRESS: 'Full address',
DRIVER_LICENSE: 'Driver license number',
LICENSE_IMAGE: 'License image',
LICENSE_APPROVAL: 'License approval',
}
function DeclineModal({ onConfirm, onCancel, loading }: { onConfirm: (reason: string) => void; onCancel: () => void; loading: boolean }) {
const [reason, setReason] = useState('')
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/40 p-4" onClick={onCancel}>
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="text-base font-semibold text-slate-900">Decline reservation</h3>
<p className="mt-1 text-sm text-slate-500">Optionally provide a reason it will be visible in the reservation record.</p>
<textarea
rows={3}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. Vehicle not available for these dates"
className="mt-4 w-full rounded-xl border border-slate-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900 resize-none"
/>
<div className="mt-4 flex gap-3">
<button onClick={onCancel} className="flex-1 rounded-full border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700">Cancel</button>
<button
disabled={loading}
onClick={() => onConfirm(reason)}
className="flex-1 rounded-full bg-rose-600 px-4 py-2.5 text-sm font-semibold text-white disabled:opacity-60"
>
{loading ? 'Declining…' : 'Decline'}
</button>
</div>
</div>
</div>
)
}
export default function OnlineReservationsPage() {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Online Reservations',
subtitle: 'Requests submitted by customers through the marketplace. Confirm or decline each one.',
refresh: 'Refresh',
pendingApproval: 'Pending approval',
loading: 'Loading…',
allCaughtUp: 'All caught up — no pending requests.',
customer: 'Customer',
vehicle: 'Vehicle',
dates: 'Dates',
status: 'Status',
total: 'Total',
},
fr: {
title: 'Réservations en ligne',
subtitle: 'Demandes envoyées par les clients via la marketplace. Confirmez ou refusez chaque demande.',
refresh: 'Actualiser',
pendingApproval: 'En attente de validation',
loading: 'Chargement…',
allCaughtUp: 'Tout est à jour. Aucune demande en attente.',
customer: 'Client',
vehicle: 'Véhicule',
dates: 'Dates',
status: 'Statut',
total: 'Total',
},
ar: {
title: 'الحجوزات الإلكترونية',
subtitle: 'طلبات أرسلها العملاء عبر السوق. أكّد كل طلب أو ارفضه.',
refresh: 'تحديث',
pendingApproval: 'في انتظار الموافقة',
loading: 'جارٍ التحميل…',
allCaughtUp: 'لا توجد طلبات معلقة حالياً.',
customer: 'العميل',
vehicle: 'المركبة',
dates: 'التواريخ',
status: 'الحالة',
total: 'الإجمالي',
},
}[language]
const [rows, setRows] = useState<OnlineReservation[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [acting, setActing] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [declineTarget, setDeclineTarget] = useState<string | null>(null)
const load = useCallback(async () => {
try {
setLoading(true)
const result = await apiFetch<OnlineReservation[]>('/reservations?source=MARKETPLACE&status=DRAFT&pageSize=100')
setRows(
(result ?? []).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
)
setError(null)
} catch (err: any) {
setError(err.message ?? 'Failed to load reservations')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
async function confirm(id: string) {
setActing(id)
setActionError(null)
try {
await apiFetch(`/reservations/${id}/confirm`, { method: 'POST', body: JSON.stringify({}) })
await load()
} catch (err: any) {
setActionError(err.message ?? 'Failed to confirm reservation')
} finally {
setActing(null)
}
}
async function decline(id: string, reason: string) {
setActing(id)
setActionError(null)
try {
await apiFetch(`/reservations/${id}/cancel`, { method: 'POST', body: JSON.stringify({ reason: reason || undefined }) })
setDeclineTarget(null)
await load()
} catch (err: any) {
setActionError(err.message ?? 'Failed to decline reservation')
} finally {
setActing(null)
}
}
const pending = rows
return (
<div className="space-y-8">
{declineTarget && (
<DeclineModal
loading={acting === declineTarget}
onConfirm={(reason) => decline(declineTarget, reason)}
onCancel={() => setDeclineTarget(null)}
/>
)}
<div className="rdg-page-heading flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-blue-600" />
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
{pending.length > 0 && (
<span className="rounded-full bg-orange-500 px-2.5 py-0.5 text-xs font-bold text-white">{pending.length}</span>
)}
</div>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
<button onClick={load} className="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">
{copy.refresh}
</button>
</div>
{actionError && (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{actionError}</div>
)}
{/* Pending section */}
<section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<Clock className="h-4 w-4" /> {copy.pendingApproval}
{pending.length > 0 && <span className="ml-1 text-orange-600">({pending.length})</span>}
</h3>
{loading ? (
<div className="card p-8 text-center text-sm text-slate-400">{copy.loading}</div>
) : pending.length === 0 ? (
<div className="card flex flex-col items-center gap-3 p-10 text-center">
<CheckCircle className="h-10 w-10 text-emerald-400" />
<p className="text-sm font-medium text-slate-600">{copy.allCaughtUp}</p>
</div>
) : (
<div className="space-y-4">
{pending.map((r) => (
<ReservationCard
key={r.id}
r={r}
acting={acting === r.id}
onConfirm={() => confirm(r.id)}
onDecline={() => setDeclineTarget(r.id)}
/>
))}
</div>
)}
</section>
</div>
)
}
function ReservationCard({ r, acting, onConfirm, onDecline }: {
r: OnlineReservation
acting: boolean
onConfirm: () => void
onDecline: () => void
}) {
const days = r.totalDays
const missingItems = r.bookingRequest.documentsMissing.map((item) => MISSING_ITEM_LABEL[item] ?? item)
return (
<div className="card overflow-hidden border-l-4 border-l-orange-400">
<div className="p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
{/* Left: customer + vehicle + dates */}
<div className="space-y-3 min-w-0">
<div className="flex items-center gap-2">
<User className="h-4 w-4 flex-shrink-0 text-slate-400" />
<div>
<p className="text-sm font-semibold text-slate-900">{r.customer.firstName} {r.customer.lastName}</p>
<p className="text-xs text-slate-500">{r.customer.email}{r.customer.phone ? ` · ${r.customer.phone}` : ''}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Car className="h-4 w-4 flex-shrink-0 text-slate-400" />
<p className="text-sm text-slate-700">{r.vehicle.year} {r.vehicle.make} {r.vehicle.model} <span className="text-slate-400">· {r.vehicle.licensePlate}</span></p>
</div>
<div className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 flex-shrink-0 text-slate-400" />
<p className="text-sm text-slate-700">
{dayjs(r.startDate).format('MMM D, YYYY')} {dayjs(r.endDate).format('MMM D, YYYY')}
<span className="ml-2 text-slate-400">{days} day{days > 1 ? 's' : ''}</span>
</p>
</div>
{r.notes && (
<div className="flex items-start gap-2">
<MessageSquare className="h-4 w-4 flex-shrink-0 text-slate-400 mt-0.5" />
<p className="text-sm text-slate-600 italic">"{r.notes}"</p>
</div>
)}
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ${REQUEST_STAGE_STYLE[r.bookingRequest.stage] ?? 'bg-slate-100 text-slate-700'}`}>
{REQUEST_STAGE_LABEL[r.bookingRequest.stage] ?? r.bookingRequest.stage}
</span>
{r.bookingRequest.paymentRequired ? (
<span className="inline-flex rounded-full bg-purple-100 px-2.5 py-1 text-xs font-semibold text-purple-800">
Payment pending
</span>
) : null}
</div>
{missingItems.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{missingItems.map((item) => (
<span key={item} className="inline-flex rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-800">
{item}
</span>
))}
</div>
) : null}
</div>
</div>
{/* Right: total + actions */}
<div className="flex flex-col items-end gap-3 flex-shrink-0">
<div className="text-right">
<p className="text-xs text-slate-400">Estimated total</p>
<p className="text-xl font-bold text-slate-900">{formatCurrency(r.totalAmount, 'MAD')}</p>
<p className="text-xs text-slate-400">{formatCurrency(r.dailyRate, 'MAD')}/day</p>
</div>
<div className="flex gap-2">
<button
onClick={onDecline}
disabled={acting}
className="flex items-center gap-1.5 rounded-full border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
<XCircle className="h-4 w-4" />
Decline
</button>
<button
onClick={onConfirm}
disabled={acting}
className="flex items-center gap-1.5 rounded-full bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
<CheckCircle className="h-4 w-4" />
{acting ? 'Confirming…' : 'Confirm'}
</button>
</div>
<p className="text-xs text-slate-400">Received {dayjs(r.createdAt).format('MMM D [at] HH:mm')}</p>
</div>
</div>
</div>
<div className="border-t border-slate-100 bg-slate-50 px-5 py-2.5 flex items-center justify-between">
<p className="text-xs text-slate-400">Ref: {r.id.slice(-10).toUpperCase()}</p>
<Link href={`/reservations/${r.id}`} className="flex items-center gap-1 text-xs text-slate-500 hover:text-blue-700">
View full detail <ChevronRight className="h-3 w-3" />
</Link>
</div>
</div>
)
}
+499
View File
@@ -0,0 +1,499 @@
'use client'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle, Plus, BarChart3, FileText, TrendingUp, MapPin } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import StatCard from '@/components/ui/StatCard'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { formatCurrency } from '@rentaldrivego/types'
/**
* Location breakdown item — requires backend field on /analytics/dashboard.
* Flagged as a contract change (not faked).
*/
interface LocationBreakdown {
name: string
bookingCount: number
revenue: number
/** 0100 utilization percentage */
utilizationPct: number
}
interface DashboardData {
kpis: {
totalBookings: number
activeVehicles: number
monthlyRevenue: number
totalCustomers: number
bookingsChange: number
vehiclesChange: number
revenueChange: number
customersChange: number
/** Requires backend field — flagged as contract change */
utilizationRate?: number
/** Requires backend field — flagged as contract change */
revenuePerVehicle?: number
/** Requires backend field — flagged as contract change */
fulfillmentRate?: number
}
recentReservations: {
id: string
bookingRef: string
customerName: string
vehicleName: string
startDate: string
endDate: string
status: string
totalAmount: number
}[]
sourceBreakdown: { source: string; count: number; revenue: number }[]
/** Requires backend field — flagged as contract change */
locationBreakdown?: LocationBreakdown[]
subscription: {
status: string
planName: string
trialEndsAt: string | null
}
}
interface EmployeeProfile {
role?: string
firstName?: string
lastName?: string
email?: string
}
const STATUS_COLORS: Record<string, string> = {
CONFIRMED: 'badge-blue',
PENDING: 'badge-amber',
ACTIVE: 'badge-green',
COMPLETED: 'badge-gray',
CANCELLED: 'badge-red',
}
interface QuickAction {
icon: React.ReactNode
label: string
href: string
color: string
}
function QuickActionsSection({ actions }: { actions: QuickAction[] }) {
return (
<div className="flex flex-wrap gap-3">
{actions.map((action) => (
<Link
key={action.href}
href={action.href}
className={`rdg-quick-action flex items-center gap-2 px-4 py-3 transition-all ${action.color}`}
>
{action.icon}
<span className="text-sm font-medium">{action.label}</span>
</Link>
))}
</div>
)
}
function SubscriptionBanner({
status,
trialEndsAt,
trialEndsMsg,
trialActiveMsg,
pastDueMsg,
suspendedMsg,
manageBillingLabel,
}: {
status: string
trialEndsAt: string | null
trialEndsMsg: (date: string) => string
trialActiveMsg: string
pastDueMsg: string
suspendedMsg: string
manageBillingLabel: string
}) {
if (status === 'ACTIVE') return null
const localeDate = trialEndsAt
? new Date(trialEndsAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: ''
const configs: Record<string, { bg: string; icon: React.ReactNode; message: string }> = {
TRIALING: {
bg: 'bg-blue-500/10 border-blue-400/25 text-blue-200',
icon: <Clock className="w-4 h-4" />,
message: trialEndsAt ? trialEndsMsg(localeDate) : trialActiveMsg,
},
PAST_DUE: {
bg: 'bg-orange-500/10 border-orange-400/25 text-orange-200',
icon: <AlertTriangle className="w-4 h-4" />,
message: pastDueMsg,
},
SUSPENDED: {
bg: 'bg-red-500/10 border-red-400/25 text-red-200',
icon: <XCircle className="w-4 h-4" />,
message: suspendedMsg,
},
}
const config = configs[status]
if (!config) return null
return (
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl border mb-6 ${config.bg}`}>
{config.icon}
<p className="text-sm font-medium">{config.message}</p>
<Link href="/subscription" className="ml-auto text-sm font-semibold underline hover:no-underline">
{manageBillingLabel}
</Link>
</div>
)
}
function BranchPerformanceList({
locations,
formatCurrencyFn,
}: {
locations: LocationBreakdown[]
formatCurrencyFn: (amount: number, currency: string) => string
}) {
const maxCount = Math.max(...locations.map((l) => l.bookingCount), 1)
return (
<div className="glass-card p-6">
<div className="mb-4 flex items-center gap-2">
<MapPin className="h-4 w-4 text-orange-700 dark:text-orange-300" />
<h2 className="text-base font-semibold text-blue-950 dark:text-slate-100">Location Performance</h2>
</div>
<div className="space-y-4">
{locations.map((loc) => (
<div key={loc.name}>
<div className="mb-1 flex items-center justify-between">
<span className="text-sm font-medium text-blue-950 dark:text-slate-100">{loc.name}</span>
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">
{loc.bookingCount} bookings · {formatCurrencyFn(loc.revenue, 'MAD')}
</span>
</div>
<div className="flex items-center gap-2">
<div className="progress-bar flex-1">
<div
className="progress-fill blue"
style={{ width: `${(loc.bookingCount / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 text-right text-xs font-mono text-slate-500 dark:text-slate-400">
{loc.utilizationPct}%
</span>
</div>
</div>
))}
</div>
</div>
)
}
export default function DashboardPage() {
const { dict, language, theme } = useDashboardI18n()
const d = dict.dashboardPage
const nav = dict.nav
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const isDark = theme === 'dark'
const chartGrid = isDark ? 'rgba(59,130,246,0.14)' : 'rgba(37,99,235,0.14)'
const chartAxis = isDark ? 'rgba(59,130,246,0.20)' : 'rgba(37,99,235,0.22)'
const chartText = isDark ? '#8899b4' : '#64748b'
const chartTooltipBg = isDark ? '#111827' : '#ffffff'
const chartTooltipText = isDark ? '#e8edf5' : '#172554'
const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<{ code: string; message: string } | null>(null)
const [employeeRole, setEmployeeRole] = useState<string | null>(null)
const [employeeName, setEmployeeName] = useState<string | null>(null)
useEffect(() => {
try {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (!cached) return
const profile = JSON.parse(cached) as EmployeeProfile
setEmployeeRole(profile.role ?? null)
const fullName = [profile.firstName, profile.lastName].filter(Boolean).join(' ') || null
setEmployeeName(fullName)
} catch {}
}, [])
useEffect(() => {
apiFetch<DashboardData>('/analytics/dashboard')
.then(setData)
.catch((err) => setError({ code: err.code ?? '', message: err.message }))
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error) {
const errorMsg = error.code === 'forbidden' ? d.forbidden : error.message
return (
<div className="card p-6 text-center">
<p className="text-slate-500 text-sm dark:text-slate-400">{errorMsg}</p>
</div>
)
}
const kpis = data?.kpis ?? { totalBookings: 0, activeVehicles: 0, monthlyRevenue: 0, totalCustomers: 0, bookingsChange: 0, vehiclesChange: 0, revenueChange: 0, customersChange: 0 }
const canViewRevenue = employeeRole !== 'AGENT'
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
const formatDateYear = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
return (
<div className="space-y-8">
{/* Welcome Section */}
<div className="rdg-page-hero">
<p className="rdg-page-kicker mb-3">Rental operations</p>
<h1 className="mb-2 text-blue-950 dark:text-slate-50">
Welcome back{employeeName ? `, ${employeeName}` : ''}!
</h1>
<p className="rdg-page-description mb-0 text-sm">
Here's what's happening with your fleet today
</p>
</div>
{/* Quick Actions */}
<QuickActionsSection
actions={[
{
icon: <Plus className="w-4 h-4" />,
label: 'New Reservation',
href: '/reservations/new',
color: 'text-blue-700 dark:text-blue-300',
},
{
icon: <Car className="w-4 h-4" />,
label: nav.fleet,
href: '/fleet',
color: 'text-emerald-700 dark:text-emerald-300',
},
{
icon: <Users className="w-4 h-4" />,
label: nav.customers,
href: '/customers',
color: 'text-orange-700 dark:text-orange-300',
},
{
icon: <FileText className="w-4 h-4" />,
label: nav.reports,
href: '/reports',
color: 'text-orange-700 dark:text-orange-300',
},
]}
/>
{data?.subscription && (
<SubscriptionBanner
status={data.subscription.status}
trialEndsAt={data.subscription.trialEndsAt}
trialEndsMsg={d.trialEnds}
trialActiveMsg={d.trialActive}
pastDueMsg={d.pastDueMsg}
suspendedMsg={d.suspendedMsg}
manageBillingLabel={d.manageBilling}
/>
)}
{/* KPI Cards */}
<div className={`grid grid-cols-1 gap-4 sm:grid-cols-2 ${canViewRevenue ? 'lg:grid-cols-4' : 'lg:grid-cols-3'}`}>
<StatCard
title={d.kpiTotalBookings}
value={kpis.totalBookings.toLocaleString()}
change={kpis.bookingsChange}
vsLastMonthLabel={d.vsLastMonth}
icon={Calendar}
iconColor="text-blue-700 dark:text-blue-300"
iconBg="bg-blue-100/80 dark:bg-blue-500/10"
pulse
/>
<StatCard
title={d.kpiActiveVehicles}
value={kpis.activeVehicles.toLocaleString()}
change={kpis.vehiclesChange}
vsLastMonthLabel={d.vsLastMonth}
icon={Car}
iconColor="text-emerald-700 dark:text-emerald-300"
iconBg="bg-emerald-100/80 dark:bg-emerald-500/10"
/>
{canViewRevenue ? (
<StatCard
title={d.kpiMonthlyRevenue}
value={formatCurrency(kpis.monthlyRevenue, 'MAD')}
change={kpis.revenueChange}
vsLastMonthLabel={d.vsLastMonth}
icon={DollarSign}
iconColor="text-orange-700 dark:text-orange-300"
iconBg="bg-orange-100/80 dark:bg-orange-500/10"
valueGradient
/>
) : null}
<StatCard
title={d.kpiTotalCustomers}
value={kpis.totalCustomers.toLocaleString()}
change={kpis.customersChange}
vsLastMonthLabel={d.vsLastMonth}
icon={Users}
iconColor="text-blue-700 dark:text-blue-300"
iconBg="bg-blue-100/80 dark:bg-blue-500/10"
/>
</div>
{/* New metrics row — contract change flag */}
{kpis.utilizationRate !== undefined && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard
title="Utilization Rate"
value={`${kpis.utilizationRate}%`}
icon={TrendingUp}
iconColor="text-blue-700 dark:text-blue-300"
iconBg="bg-blue-100/80 dark:bg-blue-500/10"
progressPercent={kpis.utilizationRate}
target={100}
/>
<StatCard
title="Revenue / Vehicle"
value={formatCurrency(kpis.revenuePerVehicle ?? 0, 'MAD')}
icon={DollarSign}
iconColor="text-orange-700 dark:text-orange-300"
iconBg="bg-orange-100/80 dark:bg-orange-500/10"
/>
<StatCard
title="Fulfillment Rate"
value={`${kpis.fulfillmentRate ?? 0}%`}
icon={BarChart3}
iconColor="text-emerald-700 dark:text-emerald-300"
iconBg="bg-emerald-100/80 dark:bg-emerald-500/10"
progressPercent={kpis.fulfillmentRate ?? 0}
target={100}
/>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left column — 2/3: Booking Sources + Location Performance */}
<div className="lg:col-span-2 space-y-6">
<div className="card p-6">
<h2 className="mb-4 text-base font-semibold text-blue-950 dark:text-slate-100">{d.bookingSources}</h2>
{data?.sourceBreakdown && data.sourceBreakdown.length > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={data.sourceBreakdown}>
<CartesianGrid strokeDasharray="3 3" stroke={chartGrid} />
<XAxis dataKey="source" tick={{ fontSize: 12, fill: chartText }} axisLine={{ stroke: chartAxis }} tickLine={false} />
<YAxis tick={{ fontSize: 12, fill: chartText }} axisLine={{ stroke: chartAxis }} tickLine={false} />
<Tooltip
contentStyle={{ borderRadius: '8px', border: `1px solid ${chartAxis}`, background: chartTooltipBg, color: chartTooltipText, fontSize: '12px' }}
cursor={{ fill: 'rgba(59,130,246,0.08)' }}
/>
<Legend wrapperStyle={{ fontSize: '12px', color: chartText }} />
<Bar dataKey="count" fill="#3b82f6" name={d.barBookings} radius={[4, 4, 0, 0]} />
<Bar dataKey="revenue" fill="#f97316" name={d.barRevenue} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-48 flex items-center justify-center text-slate-400 text-sm">
{d.noBookingData}
</div>
)}
</div>
{data?.locationBreakdown && data.locationBreakdown.length > 0 ? (
<BranchPerformanceList
locations={data.locationBreakdown}
formatCurrencyFn={(a, c) => formatCurrency(a, c as 'MAD')}
/>
) : null}
</div>
{/* Right column — 1/3: Quick Stats */}
<div className="card p-6">
<h2 className="mb-4 text-base font-semibold text-blue-950 dark:text-slate-100">{d.quickStats}</h2>
<div className="space-y-4">
{(data?.sourceBreakdown ?? []).map((item) => (
<div key={item.source} className="flex items-center justify-between">
<span className="text-sm capitalize text-slate-500 dark:text-slate-400">{item.source.toLowerCase()}</span>
<div className="text-right">
<span className="text-sm font-semibold text-blue-950 dark:text-slate-100">{item.count}</span>
<p className="text-xs text-slate-400">{formatCurrency(item.revenue, 'MAD')}</p>
</div>
</div>
))}
{(!data?.sourceBreakdown || data.sourceBreakdown.length === 0) && (
<p className="text-sm text-slate-400">{d.noData}</p>
)}
</div>
</div>
</div>
{/* Recent Reservations */}
<div className="card">
<div className="flex items-center justify-between border-b border-blue-200/70 px-6 py-4 dark:border-blue-400/10">
<h2 className="text-base font-semibold text-blue-950 dark:text-slate-100">{d.recentReservations}</h2>
<Link href="/reservations" className="text-sm font-medium text-blue-700 hover:text-blue-900 dark:text-blue-300 dark:hover:text-blue-200">
{d.viewAll}
</Link>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-blue-200/70 dark:border-blue-400/10">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colBookingNum}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colCustomer}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colVehicle}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colDates}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colStatus}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colTotal}</th>
</tr>
</thead>
<tbody className="divide-y divide-blue-200/70 dark:divide-blue-400/10">
{(data?.recentReservations ?? []).map((res) => (
<tr key={res.id} className="transition-colors hover:bg-blue-500/[0.06]">
<td className="px-6 py-3">
<Link href={`/reservations/${res.id}`} className="text-sm font-medium text-blue-700 hover:text-blue-900 dark:text-blue-300 dark:hover:text-blue-200">
#{res.bookingRef}
</Link>
</td>
<td className="px-6 py-3 text-sm text-slate-700 dark:text-slate-300">{res.customerName}</td>
<td className="px-6 py-3 text-sm text-slate-700 dark:text-slate-300">{res.vehicleName}</td>
<td className="px-6 py-3 text-sm text-slate-500 dark:text-slate-400">
{formatDate(res.startDate)} {formatDateYear(res.endDate)}
</td>
<td className="px-6 py-3">
<span className={STATUS_COLORS[res.status] ?? 'badge-gray'}>
{d.statusLabels[res.status as keyof typeof d.statusLabels] ?? res.status}
</span>
</td>
<td className="px-6 py-3 text-right text-sm font-semibold text-blue-950 dark:text-slate-100">
{formatCurrency(res.totalAmount, 'MAD')}
</td>
</tr>
))}
{(!data?.recentReservations || data.recentReservations.length === 0) && (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-sm text-slate-400">
{d.noReservations}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -0,0 +1,206 @@
'use client'
import { useEffect, useState } from 'react'
import { formatCurrency } from '@rentaldrivego/types'
import { API_BASE, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface ReportRow {
reservationId: string
customerName: string
vehicle: string
startDate: string
endDate: string
totalAmount: number
source: string
paymentStatus: string
}
interface ReportData {
summary: {
totalReservations: number
totalRentalRevenue: number
totalDiscounts: number
totalInsurance: number
totalAdditionalDrivers: number
totalCollected: number
totalPricingRulesAdj?: number
}
rows: ReportRow[]
}
type ReportPeriod = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'ANNUAL'
export default function ReportsPage() {
const { language } = useDashboardI18n()
const [period, setPeriod] = useState<ReportPeriod>('MONTHLY')
const [report, setReport] = useState<ReportData | null>(null)
const [error, setError] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
const copy = {
en: {
title: 'Reports',
subtitle: 'Accountant-ready exports with totals for insurance, additional drivers, and pricing rules.',
exportCsv: 'Export CSV',
exporting: 'Exporting…',
bookings: 'Bookings',
rental: 'Rental',
insurance: 'Insurance',
drivers: 'Drivers',
discounts: 'Discounts',
collected: 'Collected',
reservation: 'Reservation',
vehicle: 'Vehicle',
period: 'Period',
source: 'Source',
payment: 'Payment',
total: 'Total',
emptyRows: 'No report rows available for this period.',
periodLabels: { WEEKLY: 'Weekly', MONTHLY: 'Monthly', QUARTERLY: 'Quarterly', ANNUAL: 'Annual' } as Record<ReportPeriod, string>,
},
fr: {
title: 'Rapports',
subtitle: 'Exports prêts pour la comptabilité avec les totaux dassurance, de conducteurs supplémentaires et de règles tarifaires.',
exportCsv: 'Exporter en CSV',
exporting: 'Export en cours…',
bookings: 'Réservations',
rental: 'Location',
insurance: 'Assurance',
drivers: 'Conducteurs',
discounts: 'Remises',
collected: 'Encaissé',
reservation: 'Réservation',
vehicle: 'Véhicule',
period: 'Période',
source: 'Source',
payment: 'Paiement',
total: 'Total',
emptyRows: 'Aucune ligne de rapport pour cette période.',
periodLabels: { WEEKLY: 'Hebdomadaire', MONTHLY: 'Mensuel', QUARTERLY: 'Trimestriel', ANNUAL: 'Annuel' } as Record<ReportPeriod, string>,
},
ar: {
title: 'التقارير',
subtitle: 'ملفات تصدير جاهزة للمحاسبة تشمل إجماليات التأمين والسائقين الإضافيين وقواعد التسعير.',
exportCsv: 'تصدير CSV',
exporting: 'جارٍ التصدير…',
bookings: 'الحجوزات',
rental: 'الإيجار',
insurance: 'التأمين',
drivers: 'السائقون',
discounts: 'الخصومات',
collected: 'المحصّل',
reservation: 'الحجز',
vehicle: 'المركبة',
period: 'الفترة',
source: 'المصدر',
payment: 'الدفع',
total: 'الإجمالي',
emptyRows: 'لا توجد بيانات تقرير لهذه الفترة.',
periodLabels: { WEEKLY: 'أسبوعي', MONTHLY: 'شهري', QUARTERLY: 'ربع سنوي', ANNUAL: 'سنوي' } as Record<ReportPeriod, string>,
},
}[language]
useEffect(() => {
apiFetch<ReportData>(`/analytics/report?period=${period}`)
.then(setReport)
.catch((err) => setError(err.message))
}, [period])
async function exportCsv() {
setExporting(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/analytics/report?period=${period}&format=CSV`, {
credentials: 'include',
})
if (!res.ok) {
const json = await res.json().catch(() => null)
throw new Error(json?.message ?? copy.exportCsv)
}
const csv = await res.text()
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = window.URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `rentaldrivego-${period.toLowerCase()}-report.csv`
anchor.click()
window.URL.revokeObjectURL(url)
} catch (err: any) {
setError(err.message ?? copy.exportCsv)
} finally {
setExporting(false)
}
}
return (
<div className="space-y-6">
<div className="rdg-page-heading 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>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(copy.periodLabels) as ReportPeriod[]).map((option) => (
<button
key={option}
onClick={() => setPeriod(option)}
className={`rounded-full px-4 py-2 text-sm font-semibold ${period === option ? 'bg-blue-900 text-white' : 'border border-slate-300 text-slate-700'}`}
>
{copy.periodLabels[option]}
</button>
))}
<button onClick={exportCsv} disabled={exporting} className="rounded-full border border-slate-900 px-4 py-2 text-sm font-semibold text-slate-900">
{exporting ? copy.exporting : copy.exportCsv}
</button>
</div>
</div>
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
{report && (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-6">
<div className="card p-5"><p className="text-sm text-slate-500">{copy.bookings}</p><p className="mt-1 text-2xl font-bold text-slate-900">{report.summary.totalReservations}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.rental}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalRentalRevenue, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.insurance}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalInsurance, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.drivers}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalAdditionalDrivers, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.discounts}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalDiscounts, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.collected}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalCollected, 'MAD')}</p></div>
</div>
)}
<div className="card overflow-hidden">
<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.reservation}</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.period}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.source}</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-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{(report?.rows ?? []).map((row) => (
<tr key={row.reservationId}>
<td className="px-6 py-4 text-sm text-slate-700">{row.customerName}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.vehicle}</td>
<td className="px-6 py-4 text-sm text-slate-500">{row.startDate} - {row.endDate}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.source}</td>
<td className="px-6 py-4"><span className="badge-gray">{row.paymentStatus}</span></td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
</tr>
))}
{(report?.rows.length ?? 0) === 0 && (
<tr>
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{copy.emptyRows}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -0,0 +1,910 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import DamageInspectionCard, { DamageInspection } from '@/components/reservations/DamageInspectionCard'
import ReservationPhotoSection from '@/components/reservations/ReservationPhotoSection'
interface ReservationDetail {
id: string
status: string
source: string
startDate: string
endDate: string
totalAmount: number
discountAmount: number
insuranceTotal: number
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
lastName: string
email: string
phone: string | null
driverLicense: string | null
licenseImageUrl: string | null
licenseValidationStatus: string
flagged: boolean
}
vehicle: {
make: string
model: string
licensePlate: string
}
insurances: { id: string; policyName: string; totalCharge: number }[]
additionalDrivers: {
id: string
firstName: string
lastName: string
driverLicense: string
totalCharge: number
requiresApproval: boolean
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
}
bookingRequest: {
stage: string
companyConfirmed: boolean
documentsRequired: boolean
paymentRequired: boolean
documentsMissing: string[]
}
}
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.',
licenseImageLabel: 'License image',
bookingProgress: 'Booking progress',
paymentPending: 'Payment or deposit still pending',
stageLabels: {
REQUEST_SENT: 'Waiting for company review',
COMPANY_CONFIRMED: 'Availability confirmed',
DOCUMENTS_REQUIRED: 'Driver documents missing',
PAYMENT_REQUIRED: 'Payment or deposit pending',
BOOKING_CONFIRMED: 'Ready to proceed',
CANCELLED: 'Request closed',
},
missingItems: {
DATE_OF_BIRTH: 'Date of birth',
IDENTITY_DOCUMENT: 'Identity document',
FULL_ADDRESS: 'Full address',
DRIVER_LICENSE: 'Driver license number',
LICENSE_IMAGE: 'License image',
LICENSE_APPROVAL: 'License approval',
},
noLicenseImage: 'No license image uploaded.',
pickupPhotosTitle: 'Pickup photos',
dropoffPhotosTitle: 'Return photos',
pickupPhotosReadOnly: 'Pickup photos can be added once the reservation is confirmed.',
dropoffPhotosReadOnly: 'Return photos become available once the vehicle is checked out.',
extendBtn: 'Extend rental',
extendTitle: 'Extend rental',
extendNewEnd: 'New return date & time',
extendReason: 'Reason for extension',
extendReasonPlaceholder: 'e.g. Customer requested 2 extra days',
extendSubmit: 'Confirm extension',
extendCancel: 'Cancel',
extendFailed: 'Failed to extend reservation',
},
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.',
licenseImageLabel: 'Image du permis',
bookingProgress: 'Progression de la réservation',
paymentPending: 'Paiement ou dépôt encore en attente',
stageLabels: {
REQUEST_SENT: 'En attente de validation',
COMPANY_CONFIRMED: 'Disponibilité confirmée',
DOCUMENTS_REQUIRED: 'Documents conducteur manquants',
PAYMENT_REQUIRED: 'Paiement ou dépôt en attente',
BOOKING_CONFIRMED: 'Prêt à avancer',
CANCELLED: 'Demande clôturée',
},
missingItems: {
DATE_OF_BIRTH: 'Date de naissance',
IDENTITY_DOCUMENT: 'Pièce didentité',
FULL_ADDRESS: 'Adresse complète',
DRIVER_LICENSE: 'Numéro du permis',
LICENSE_IMAGE: 'Image du permis',
LICENSE_APPROVAL: 'Validation du permis',
},
noLicenseImage: 'Aucune image de permis téléversée.',
pickupPhotosTitle: 'Photos de départ',
dropoffPhotosTitle: 'Photos de retour',
pickupPhotosReadOnly: 'Les photos de départ sont disponibles après confirmation de la réservation.',
dropoffPhotosReadOnly: 'Les photos de retour sont disponibles après la restitution du véhicule.',
extendBtn: 'Prolonger la location',
extendTitle: 'Prolonger la location',
extendNewEnd: 'Nouvelle date de retour',
extendReason: 'Motif de prolongation',
extendReasonPlaceholder: 'ex. Le client a demandé 2 jours supplémentaires',
extendSubmit: 'Confirmer la prolongation',
extendCancel: 'Annuler',
extendFailed: 'Échec de la prolongation',
},
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: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.',
licenseImageLabel: 'صورة الرخصة',
bookingProgress: 'تقدم الحجز',
paymentPending: 'الدفع أو العربون ما زال معلقاً',
stageLabels: {
REQUEST_SENT: 'بانتظار مراجعة الشركة',
COMPANY_CONFIRMED: 'تم تأكيد التوفر',
DOCUMENTS_REQUIRED: 'وثائق السائق ناقصة',
PAYMENT_REQUIRED: 'الدفع أو العربون معلق',
BOOKING_CONFIRMED: 'جاهز للمتابعة',
CANCELLED: 'تم إغلاق الطلب',
},
missingItems: {
DATE_OF_BIRTH: 'تاريخ الميلاد',
IDENTITY_DOCUMENT: 'وثيقة الهوية',
FULL_ADDRESS: 'العنوان الكامل',
DRIVER_LICENSE: 'رقم الرخصة',
LICENSE_IMAGE: 'صورة الرخصة',
LICENSE_APPROVAL: 'اعتماد الرخصة',
},
noLicenseImage: 'لم يتم رفع صورة الرخصة.',
pickupPhotosTitle: 'صور الاستلام',
dropoffPhotosTitle: 'صور الإرجاع',
pickupPhotosReadOnly: 'تتوفر صور الاستلام بعد تأكيد الحجز.',
dropoffPhotosReadOnly: 'تتوفر صور الإرجاع بعد إعادة السيارة.',
extendBtn: 'تمديد التأجير',
extendTitle: 'تمديد التأجير',
extendNewEnd: 'تاريخ الإرجاع الجديد',
extendReason: 'سبب التمديد',
extendReasonPlaceholder: 'مثال: طلب العميل يومين إضافيين',
extendSubmit: 'تأكيد التمديد',
extendCancel: 'إلغاء',
extendFailed: 'فشل تمديد الحجز',
},
} 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)
const [showExtend, setShowExtend] = useState(false)
const [extendForm, setExtendForm] = useState({ newEndDate: '', reason: '' })
const [extendError, setExtendError] = useState<string | null>(null)
async function loadReservation() {
try {
const [reservationData, inspectionData] = await Promise.all([
apiFetch<ReservationDetail>(`/reservations/${params.id}`),
apiFetch<DamageInspection[]>(`/reservations/${params.id}/inspections`),
])
setReservation(reservationData)
setInspections(inspectionData)
setForm(toFormState(reservationData))
setEditMode(null)
setError(null)
} catch (err: any) {
setError(err.message ?? r.failedToLoad)
}
}
useEffect(() => {
loadReservation()
}, [params.id])
async function runAction(action: 'confirm' | 'checkin' | 'checkout') {
setActing(true)
setActionError(null)
const errorMsg = action === 'confirm' ? r.failedConfirm : action === 'checkin' ? r.failedCheckIn : r.failedCheckOut
try {
await apiFetch(`/reservations/${params.id}/${action}`, {
method: 'POST',
body: JSON.stringify({}),
})
await loadReservation()
} catch (err: any) {
setActionError(err.message ?? errorMsg)
} finally {
setActing(false)
}
}
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)
try {
await apiFetch(`/reservations/${params.id}/additional-drivers/${driverId}/approval`, {
method: 'PATCH',
body: JSON.stringify({ approved: true }),
})
await loadReservation()
} catch (err: any) {
setActionError(err.message ?? r.failedApproveDriver)
} finally {
setActing(false)
}
}
async function submitExtend() {
if (!extendForm.newEndDate || !extendForm.reason.trim()) return
setActing(true)
setExtendError(null)
try {
await apiFetch(`/reservations/${params.id}/extend`, {
method: 'POST',
body: JSON.stringify({ newEndDate: new Date(extendForm.newEndDate).toISOString(), reason: extendForm.reason }),
})
setShowExtend(false)
setExtendForm({ newEndDate: '', reason: '' })
await loadReservation()
} catch (err: any) {
setExtendError(err.message ?? copy.extendFailed)
} finally {
setActing(false)
}
}
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
if (!reservation || !form) return <div className="card p-6 text-sm text-slate-500">{r.loadingReservation}</div>
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">
<div className="rdg-page-heading flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<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">
{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}
</button>
)}
{reservation.status === 'CONFIRMED' && (
<button disabled={acting} onClick={() => runAction('checkin')} className="btn-primary">
{acting ? r.working : r.checkInVehicle}
</button>
)}
{reservation.status === 'ACTIVE' && (
<button disabled={acting} onClick={() => runAction('checkout')} className="btn-primary">
{acting ? r.working : r.checkOutVehicle}
</button>
)}
{['CONFIRMED', 'ACTIVE'].includes(reservation.status) && !reservation.workflow.closed && (
<button disabled={acting} onClick={() => { setShowExtend(true); setExtendError(null) }} className="btn-secondary">
{copy.extendBtn}
</button>
)}
</div>
</div>
{showExtend && (
<div className="card border border-blue-200 bg-blue-50 p-5 space-y-4">
<h4 className="text-sm font-semibold text-slate-900">{copy.extendTitle}</h4>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs font-medium text-slate-700">{copy.extendNewEnd}</label>
<input
type="datetime-local"
className="input-field"
value={extendForm.newEndDate}
onChange={(e) => setExtendForm((f) => ({ ...f, newEndDate: e.target.value }))}
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-slate-700">{copy.extendReason}</label>
<input
className="input-field"
placeholder={copy.extendReasonPlaceholder}
value={extendForm.reason}
onChange={(e) => setExtendForm((f) => ({ ...f, reason: e.target.value }))}
/>
</div>
</div>
{extendError && <p className="text-xs text-red-600">{extendError}</p>}
<div className="flex gap-3">
<button disabled={acting} onClick={submitExtend} className="btn-primary text-sm">
{acting ? r.working : copy.extendSubmit}
</button>
<button onClick={() => { setShowExtend(false); setExtendForm({ newEndDate: '', reason: '' }) }} className="btn-secondary text-sm">
{copy.extendCancel}
</button>
</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>}
{reservation.source === 'MARKETPLACE' ? (
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{copy.bookingProgress}</h3>
<div className="flex flex-wrap items-center gap-2">
<span className="badge-gray">
{copy.stageLabels[reservation.bookingRequest.stage as keyof typeof copy.stageLabels] ?? reservation.bookingRequest.stage}
</span>
{reservation.bookingRequest.paymentRequired ? (
<span className="rounded-full bg-purple-100 px-2.5 py-1 text-xs font-semibold text-purple-800">
{copy.paymentPending}
</span>
) : null}
</div>
{reservation.bookingRequest.documentsMissing.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{reservation.bookingRequest.documentsMissing.map((item) => (
<span key={item} className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">
{copy.missingItems[item as keyof typeof copy.missingItems] ?? item}
</span>
))}
</div>
) : null}
</div>
) : null}
<div className="grid gap-6 lg:grid-cols-2">
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCustomer}</h3>
<div className="space-y-2 text-sm">
<p className="font-medium text-slate-900">{reservation.customer.firstName} {reservation.customer.lastName}</p>
<p className="text-slate-600">{reservation.customer.email}</p>
<p className="text-slate-600">{reservation.customer.phone ?? r.noPhone}</p>
<p className="text-slate-600">{r.licenseLabel} {reservation.customer.driverLicense ?? r.notCaptured}</p>
<div className="pt-2">
<p className="text-slate-600">{copy.licenseImageLabel}</p>
{reservation.customer.licenseImageUrl ? (
<a href={reservation.customer.licenseImageUrl} target="_blank" rel="noreferrer" className="mt-2 block max-w-sm">
<img
src={reservation.customer.licenseImageUrl}
alt="Driver license"
className="h-40 w-full rounded-xl border border-slate-200 object-cover"
/>
</a>
) : (
<p className="text-xs text-slate-500">{copy.noLicenseImage}</p>
)}
</div>
<div className="flex flex-wrap gap-2 pt-2">
<span className="badge-gray">{reservation.customer.licenseValidationStatus}</span>
{reservation.customer.flagged && <span className="badge-red">{r.flaggedBadge}</span>}
</div>
</div>
</div>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionVehicle}</h3>
<div className="space-y-2 text-sm text-slate-600">
<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>
<ReservationPhotoSection
reservationId={reservation.id}
type="PICKUP"
editable={reservation.workflow.checkInInspectionEditable}
title={copy.pickupPhotosTitle}
readOnlyMessage={copy.pickupPhotosReadOnly}
/>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{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>
<ReservationPhotoSection
reservationId={reservation.id}
type="DROPOFF"
editable={reservation.workflow.checkOutInspectionEditable}
title={copy.dropoffPhotosTitle}
readOnlyMessage={copy.dropoffPhotosReadOnly}
/>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCharges}</h3>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div><dt className="text-slate-500">{r.chargeDiscount}</dt><dd className="text-slate-900">{formatCurrency(reservation.discountAmount, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargeInsurance}</dt><dd className="text-slate-900">{formatCurrency(reservation.insuranceTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargeAdditionalDrivers}</dt><dd className="text-slate-900">{formatCurrency(reservation.additionalDriverTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargePricingAdjustments}</dt><dd className="text-slate-900">{formatCurrency(reservation.pricingRulesTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargeGrandTotal}</dt><dd className="font-semibold text-slate-900">{formatCurrency(reservation.totalAmount, 'MAD')}</dd></div>
<div><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 && (
<div className="mt-5">
<p className="mb-2 text-sm font-semibold text-slate-900">{r.appliedInsurance}</p>
<div className="space-y-2">
{reservation.insurances.map((insurance) => (
<div key={insurance.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
<span className="text-slate-700">{insurance.policyName}</span>
<span className="font-semibold text-slate-900">{formatCurrency(insurance.totalCharge, 'MAD')}</span>
</div>
))}
</div>
</div>
)}
{reservation.pricingRulesApplied && reservation.pricingRulesApplied.length > 0 && (
<div className="mt-5">
<p className="mb-2 text-sm font-semibold text-slate-900">{r.pricingRulesApplied}</p>
<div className="space-y-2">
{reservation.pricingRulesApplied.map((rule) => (
<div key={`${rule.name}-${rule.amount}`} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
<span className="text-slate-700">{rule.name}</span>
<span className={rule.amount < 0 ? 'font-semibold text-emerald-700' : 'font-semibold text-orange-700'}>
{rule.amount < 0 ? '-' : '+'}{formatCurrency(Math.abs(rule.amount), 'MAD')}
</span>
</div>
))}
</div>
</div>
)}
</div>
<DamageInspectionCard
reservationId={reservation.id}
type="CHECKIN"
initialInspection={checkinInspection}
editable={reservation.workflow.checkInInspectionEditable}
readOnlyMessage={checkInReadOnlyMessage}
onSaved={(inspection) => setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])}
/>
<DamageInspectionCard
reservationId={reservation.id}
type="CHECKOUT"
initialInspection={checkoutInspection}
comparisonPoints={checkinInspection?.damagePoints ?? []}
editable={reservation.workflow.checkOutInspectionEditable}
readOnlyMessage={checkOutReadOnlyMessage}
onSaved={(inspection) => setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])}
/>
</div>
<div className="space-y-6">
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionAdditionalDrivers}</h3>
<div className="space-y-3">
{reservation.additionalDrivers.map((driver) => (
<div key={driver.id} className="rounded-2xl border border-slate-200 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium text-slate-900">{driver.firstName} {driver.lastName}</p>
<p className="text-sm text-slate-500">{r.driverLicenseLabel} {driver.driverLicense}</p>
<p className="mt-1 text-sm text-slate-600">{r.driverChargeLabel} {formatCurrency(driver.totalCharge, 'MAD')}</p>
</div>
{driver.requiresApproval && !driver.approvedAt ? (
<button onClick={() => approveDriver(driver.id)} disabled={acting} className="rounded-full bg-orange-600 px-4 py-2 text-xs font-semibold text-white">
{r.approveBtn}
</button>
) : (
<span className={driver.approvedAt ? 'badge-green' : 'badge-gray'}>
{driver.approvedAt ? r.approvedBadge : r.noApprovalNeeded}
</span>
)}
</div>
{driver.approvalNote && <p className="mt-2 text-xs text-orange-700">{driver.approvalNote}</p>}
</div>
))}
{reservation.additionalDrivers.length === 0 && (
<div className="text-sm text-slate-400">{r.noAdditionalDrivers}</div>
)}
</div>
</div>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionInspectionSummary}</h3>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-slate-600">{r.checkInInspectionLabel}</span>
<span className={checkinInspection ? 'badge-green' : 'badge-gray'}>{checkinInspection ? r.savedBadge : r.pendingBadge}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-slate-600">{r.checkOutInspectionLabel}</span>
<span className={checkoutInspection ? 'badge-green' : 'badge-gray'}>{checkoutInspection ? r.savedBadge : r.pendingBadge}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-slate-600">{copy.returnLabel}</span>
<span className="font-medium text-slate-900">{reservation.returnLocation ?? copy.noLocation}</span>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,808 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { BilingualField, BilingualInput, bilingualPrimary, emptyBilingual } from '@/components/ui/BilingualInput'
type Customer = {
id: string
firstName: string
lastName: string
email: string
phone?: string | null
driverLicense?: string | null
dateOfBirth?: string | null
nationality?: string | null
address?: Record<string, unknown> | null
licenseExpiry?: string | null
licenseIssuedAt?: string | null
licenseCountry?: string | null
licenseNumber?: string | null
licenseCategory?: string | null
licenseImageUrl?: string | null
}
type Vehicle = { id: string; make: string; model: string; licensePlate: string; status: string }
type AdditionalDriverForm = {
firstName: BilingualField
lastName: BilingualField
email: string
phone: string
driverLicense: string
licenseExpiry: string
licenseIssuedAt: string
dateOfBirth: string
nationality: BilingualField
}
type CreatedReservation = { id: string }
export default function NewReservationPage() {
const { language } = useDashboardI18n()
const router = useRouter()
const [customers, setCustomers] = useState<Customer[]>([])
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [customerId, setCustomerId] = useState('')
const [customerSearch, setCustomerSearch] = useState('')
const [vehicleId, setVehicleId] = useState('')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [pickupLocation, setPickupLocation] = useState('')
const [returnLocation, setReturnLocation] = useState('')
const [depositAmount, setDepositAmount] = useState('0')
const [paymentMode, setPaymentMode] = useState('CASH')
const [notes, setNotes] = useState('')
const [showAddCustomer, setShowAddCustomer] = useState(false)
const [savingCustomer, setSavingCustomer] = useState(false)
const [includeAdditionalDriver, setIncludeAdditionalDriver] = useState(false)
const [newCustomerFirstName, setNewCustomerFirstName] = useState<BilingualField>(emptyBilingual())
const [newCustomerLastName, setNewCustomerLastName] = useState<BilingualField>(emptyBilingual())
const [newCustomerEmail, setNewCustomerEmail] = useState('')
const [newCustomerPhone, setNewCustomerPhone] = useState('')
const [driverLicense, setDriverLicense] = useState('')
const [customerDateOfBirth, setCustomerDateOfBirth] = useState('')
const [customerNationality, setCustomerNationality] = useState('')
const [customerFullAddress, setCustomerFullAddress] = useState('')
const [customerIdentityDocumentNumber, setCustomerIdentityDocumentNumber] = useState('')
const [customerInternationalLicenseNumber, setCustomerInternationalLicenseNumber] = useState('')
const [licenseExpiry, setLicenseExpiry] = useState('')
const [licenseIssuedAt, setLicenseIssuedAt] = useState('')
const [licenseCountry, setLicenseCountry] = useState('')
const [licenseCategory, setLicenseCategory] = useState('')
const [licenseImageUrl, setLicenseImageUrl] = useState<string | null>(null)
const [licenseImageFile, setLicenseImageFile] = useState<File | null>(null)
const [licenseImagePreviewUrl, setLicenseImagePreviewUrl] = useState<string | null>(null)
const [additionalDriver, setAdditionalDriver] = useState<AdditionalDriverForm>({
firstName: emptyBilingual(),
lastName: emptyBilingual(),
email: '',
phone: '',
driverLicense: '',
licenseExpiry: '',
licenseIssuedAt: '',
dateOfBirth: '',
nationality: emptyBilingual(),
})
const copy = {
en: {
title: 'Local Booking',
subtitle: 'Create a reservation directly from workspace without a previous online reservation.',
customer: 'Customer',
searchCustomer: 'Search previous customer',
vehicle: 'Vehicle',
startDate: 'Start date & time',
endDate: 'End date & time',
pickup: 'Pickup location',
return: 'Return location',
deposit: 'Deposit amount (MAD)',
paymentMode: 'Payment mode',
renterIdentity: 'Renter identity',
driverLicenseInfo: 'Driver license information',
driverLicense: 'Driver license number',
licenseExpiry: 'License expiry',
licenseIssuedAt: 'License issued at',
licenseCountry: 'License country',
licenseCategory: 'License category',
fullAddress: 'Full address',
identityDocumentNumber: 'CIN / Passport number',
internationalLicenseNumber: 'International permit number',
licenseImage: 'Driver license image',
licenseImageHint: 'Upload a photo or scan of the primary driver license.',
licenseImageSelected: 'Selected file',
licenseImageCurrent: 'Current image',
noLicenseImage: 'No license image uploaded yet.',
addAdditionalDriver: 'Add additional driver',
additionalDriverInfo: 'Additional driver',
dateOfBirth: 'Date of birth',
nationality: 'Nationality',
notes: 'Notes',
notesPlaceholder: 'Optional notes…',
create: 'Create booking',
creating: 'Creating…',
cancel: 'Cancel',
loadFailed: 'Failed to load customers/vehicles.',
invalidDates: 'End date must be after start date.',
required: 'Please fill all required fields.',
selectCustomer: 'Select customer…',
addCustomer: 'Add customer',
addCustomerTitle: 'Add new customer',
firstName: 'First name',
lastName: 'Last name',
email: 'Email',
phone: 'Phone',
saveCustomer: 'Save customer',
savingCustomer: 'Saving customer…',
selectVehicle: 'Select vehicle…',
noVehicles: 'No available vehicles.',
paymentModes: {
CASH: 'Cash',
CARD: 'Card',
BANK_TRANSFER: 'Bank transfer',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
fr: {
title: 'Réservation locale',
subtitle: 'Créez une réservation directement depuis lespace, sans réservation en ligne préalable.',
customer: 'Client',
searchCustomer: 'Rechercher un client existant',
vehicle: 'Véhicule',
startDate: 'Date et heure de départ',
endDate: 'Date et heure de retour',
pickup: 'Lieu de départ',
return: 'Lieu de retour',
deposit: 'Montant du dépôt (MAD)',
paymentMode: 'Mode de paiement',
renterIdentity: 'Identité du locataire',
driverLicenseInfo: 'Informations du permis',
driverLicense: 'Numéro du permis',
licenseExpiry: 'Expiration du permis',
licenseIssuedAt: 'Permis délivré le',
licenseCountry: 'Pays du permis',
licenseCategory: 'Catégorie du permis',
fullAddress: 'Adresse complète',
identityDocumentNumber: 'N° CIN / passeport',
internationalLicenseNumber: 'N° de permis international',
licenseImage: 'Image du permis',
licenseImageHint: 'Téléversez une photo ou un scan du permis du conducteur principal.',
licenseImageSelected: 'Fichier sélectionné',
licenseImageCurrent: 'Image actuelle',
noLicenseImage: 'Aucune image de permis téléversée.',
addAdditionalDriver: 'Ajouter un conducteur supplémentaire',
additionalDriverInfo: 'Conducteur supplémentaire',
dateOfBirth: 'Date de naissance',
nationality: 'Nationalité',
notes: 'Notes',
notesPlaceholder: 'Notes optionnelles…',
create: 'Créer la réservation',
creating: 'Création…',
cancel: 'Annuler',
loadFailed: 'Échec du chargement des clients/véhicules.',
invalidDates: 'La date de fin doit être après la date de début.',
required: 'Veuillez remplir tous les champs requis.',
selectCustomer: 'Sélectionner un client…',
addCustomer: 'Ajouter un client',
addCustomerTitle: 'Ajouter un nouveau client',
firstName: 'Prénom',
lastName: 'Nom',
email: 'Email',
phone: 'Téléphone',
saveCustomer: 'Enregistrer le client',
savingCustomer: 'Enregistrement du client…',
selectVehicle: 'Sélectionner un véhicule…',
noVehicles: 'Aucun véhicule disponible.',
paymentModes: {
CASH: 'Espèces',
CARD: 'Carte',
BANK_TRANSFER: 'Virement',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
ar: {
title: 'حجز محلي',
subtitle: 'أنشئ حجزًا مباشرة من مساحة العمل بدون حجز إلكتروني مسبق.',
customer: 'العميل',
searchCustomer: 'ابحث عن عميل سابق',
vehicle: 'المركبة',
startDate: 'تاريخ ووقت البداية',
endDate: 'تاريخ ووقت النهاية',
pickup: 'موقع الاستلام',
return: 'موقع التسليم',
deposit: 'مبلغ العربون (MAD)',
paymentMode: 'طريقة الدفع',
renterIdentity: 'بيانات المستأجر',
driverLicenseInfo: 'معلومات رخصة القيادة',
driverLicense: 'رقم رخصة القيادة',
licenseExpiry: 'تاريخ انتهاء الرخصة',
licenseIssuedAt: 'تاريخ إصدار الرخصة',
licenseCountry: 'بلد الرخصة',
licenseCategory: 'فئة الرخصة',
fullAddress: 'العنوان الكامل',
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
internationalLicenseNumber: 'رقم الرخصة الدولية',
licenseImage: 'صورة رخصة القيادة',
licenseImageHint: 'ارفع صورة أو مسحًا لرخصة السائق الأساسي.',
licenseImageSelected: 'الملف المحدد',
licenseImageCurrent: 'الصورة الحالية',
noLicenseImage: 'لم يتم رفع صورة الرخصة بعد.',
addAdditionalDriver: 'إضافة سائق إضافي',
additionalDriverInfo: 'السائق الإضافي',
dateOfBirth: 'تاريخ الميلاد',
nationality: 'الجنسية',
notes: 'ملاحظات',
notesPlaceholder: 'ملاحظات اختيارية…',
create: 'إنشاء الحجز',
creating: 'جارٍ الإنشاء…',
cancel: 'إلغاء',
loadFailed: 'فشل تحميل العملاء/المركبات.',
invalidDates: 'يجب أن يكون تاريخ النهاية بعد تاريخ البداية.',
required: 'يرجى تعبئة جميع الحقول المطلوبة.',
selectCustomer: 'اختر عميلًا…',
addCustomer: 'إضافة عميل',
addCustomerTitle: 'إضافة عميل جديد',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
saveCustomer: 'حفظ العميل',
savingCustomer: 'جارٍ حفظ العميل…',
selectVehicle: 'اختر مركبة…',
noVehicles: 'لا توجد مركبات متاحة.',
paymentModes: {
CASH: 'نقدا',
CARD: 'بطاقة',
BANK_TRANSFER: 'تحويل بنكي',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
}[language]
useEffect(() => {
Promise.all([
apiFetch<Customer[]>('/customers?pageSize=100'),
apiFetch<Vehicle[]>('/vehicles?pageSize=100'),
])
.then(([c, v]) => {
setCustomers(c ?? [])
setVehicles(v ?? [])
})
.catch((err) => setError(err.message ?? copy.loadFailed))
.finally(() => setLoading(false))
}, [])
const filteredCustomers = (() => {
const q = customerSearch.trim().toLowerCase()
if (!q) return customers
return customers.filter((c) =>
`${c.firstName} ${c.lastName}`.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q),
)
})()
const availableVehicles = vehicles.filter((v) => v.status === 'AVAILABLE')
const canSubmit = !!customerId && !!vehicleId && !!startDate && !!endDate
function readCustomerAddressValue(customer: Customer | undefined, key: string) {
const address = customer?.address
if (!address || typeof address !== 'object' || Array.isArray(address)) return ''
const value = address[key]
return typeof value === 'string' ? value : ''
}
useEffect(() => {
const selected = customers.find((customer) => customer.id === customerId)
if (!selected) return
setDriverLicense(selected.driverLicense ?? selected.licenseNumber ?? '')
setCustomerDateOfBirth(selected.dateOfBirth ? selected.dateOfBirth.slice(0, 10) : '')
setCustomerNationality(selected.nationality ?? '')
setCustomerFullAddress(readCustomerAddressValue(selected, 'fullAddress'))
setCustomerIdentityDocumentNumber(readCustomerAddressValue(selected, 'identityDocumentNumber'))
setCustomerInternationalLicenseNumber(readCustomerAddressValue(selected, 'internationalLicenseNumber'))
setLicenseExpiry(selected.licenseExpiry ? selected.licenseExpiry.slice(0, 10) : '')
setLicenseIssuedAt(selected.licenseIssuedAt ? selected.licenseIssuedAt.slice(0, 10) : '')
setLicenseCountry(selected.licenseCountry ?? '')
setLicenseCategory(selected.licenseCategory ?? '')
setLicenseImageUrl(selected.licenseImageUrl ?? null)
setLicenseImageFile(null)
}, [customerId, customers])
useEffect(() => {
if (!licenseImageFile) {
setLicenseImagePreviewUrl(null)
return
}
const objectUrl = URL.createObjectURL(licenseImageFile)
setLicenseImagePreviewUrl(objectUrl)
return () => URL.revokeObjectURL(objectUrl)
}, [licenseImageFile])
async function uploadLicenseImage(customerIdToUpdate: string) {
if (!licenseImageFile) return null
const formData = new FormData()
formData.append('file', licenseImageFile)
const updated = await apiFetch<Customer>(`/customers/${customerIdToUpdate}/license-image`, {
method: 'POST',
body: formData,
})
setCustomers((prev) => prev.map((customer) => customer.id === updated.id ? { ...customer, ...updated } : customer))
setLicenseImageUrl(updated.licenseImageUrl ?? null)
setLicenseImageFile(null)
return updated.licenseImageUrl ?? null
}
async function addCustomer() {
setError(null)
if (!bilingualPrimary(newCustomerFirstName).trim() || !bilingualPrimary(newCustomerLastName).trim() || !newCustomerEmail.trim() || !newCustomerPhone.trim()) {
setError(copy.required)
return
}
if (
!driverLicense.trim() ||
!customerDateOfBirth ||
!customerNationality.trim() ||
!customerFullAddress.trim() ||
!customerIdentityDocumentNumber.trim() ||
!licenseExpiry ||
!licenseIssuedAt ||
!licenseCountry.trim() ||
!licenseCategory.trim()
) {
setError(copy.required)
return
}
setSavingCustomer(true)
try {
const created = await apiFetch<Customer>('/customers', {
method: 'POST',
body: JSON.stringify({
firstName: bilingualPrimary(newCustomerFirstName).trim(),
firstNameAr: newCustomerFirstName.ar.trim() || undefined,
lastName: bilingualPrimary(newCustomerLastName).trim(),
lastNameAr: newCustomerLastName.ar.trim() || undefined,
email: newCustomerEmail.trim(),
phone: newCustomerPhone.trim(),
driverLicense: driverLicense.trim(),
dateOfBirth: new Date(customerDateOfBirth).toISOString(),
nationality: customerNationality.trim(),
address: {
fullAddress: customerFullAddress.trim(),
identityDocumentNumber: customerIdentityDocumentNumber.trim(),
internationalLicenseNumber: customerInternationalLicenseNumber.trim() || undefined,
},
licenseExpiry: new Date(licenseExpiry).toISOString(),
licenseIssuedAt: new Date(licenseIssuedAt).toISOString(),
licenseCountry: licenseCountry.trim(),
licenseNumber: driverLicense.trim(),
licenseCategory: licenseCategory.trim(),
}),
})
setCustomers((prev) => [created, ...prev])
setCustomerId(created.id)
setCustomerSearch(`${bilingualPrimary(newCustomerFirstName)} ${bilingualPrimary(newCustomerLastName)}`)
setShowAddCustomer(false)
setNewCustomerFirstName(emptyBilingual())
setNewCustomerLastName(emptyBilingual())
setNewCustomerEmail('')
setNewCustomerPhone('')
setLicenseImageUrl(created.licenseImageUrl ?? null)
if (licenseImageFile) {
await uploadLicenseImage(created.id)
}
} catch (err: any) {
setError(err.message)
} finally {
setSavingCustomer(false)
}
}
async function submit() {
setError(null)
if (!canSubmit) {
setError(copy.required)
return
}
const start = new Date(startDate)
const end = new Date(endDate)
if (end <= start) {
setError(copy.invalidDates)
return
}
if (
!driverLicense.trim() ||
!customerDateOfBirth ||
!customerNationality.trim() ||
!customerFullAddress.trim() ||
!customerIdentityDocumentNumber.trim() ||
!licenseExpiry ||
!licenseIssuedAt ||
!licenseCountry.trim() ||
!licenseCategory.trim()
) {
setError(copy.required)
return
}
if (includeAdditionalDriver && (!bilingualPrimary(additionalDriver.firstName).trim() || !bilingualPrimary(additionalDriver.lastName).trim() || !additionalDriver.driverLicense.trim())) {
setError(copy.required)
return
}
setSaving(true)
try {
await apiFetch(`/customers/${customerId}`, {
method: 'PATCH',
body: JSON.stringify({
driverLicense: driverLicense.trim(),
dateOfBirth: new Date(customerDateOfBirth).toISOString(),
nationality: customerNationality.trim(),
address: {
fullAddress: customerFullAddress.trim(),
identityDocumentNumber: customerIdentityDocumentNumber.trim(),
internationalLicenseNumber: customerInternationalLicenseNumber.trim() || undefined,
},
licenseExpiry: new Date(licenseExpiry).toISOString(),
licenseIssuedAt: new Date(licenseIssuedAt).toISOString(),
licenseCountry: licenseCountry.trim(),
licenseNumber: driverLicense.trim(),
licenseCategory: licenseCategory.trim(),
}),
})
if (licenseImageFile) {
await uploadLicenseImage(customerId)
}
const created = await apiFetch<CreatedReservation>('/reservations', {
method: 'POST',
body: JSON.stringify({
customerId,
vehicleId,
startDate: start.toISOString(),
endDate: end.toISOString(),
pickupLocation: pickupLocation || undefined,
returnLocation: returnLocation || undefined,
depositAmount: Number.isFinite(Number(depositAmount)) ? Math.max(0, Math.round(Number(depositAmount))) : 0,
paymentMode,
additionalDrivers: includeAdditionalDriver ? [{
firstName: bilingualPrimary(additionalDriver.firstName).trim(),
firstNameAr: additionalDriver.firstName.ar.trim() || undefined,
lastName: bilingualPrimary(additionalDriver.lastName).trim(),
lastNameAr: additionalDriver.lastName.ar.trim() || undefined,
email: additionalDriver.email.trim() || undefined,
phone: additionalDriver.phone.trim() || undefined,
driverLicense: additionalDriver.driverLicense.trim(),
licenseExpiry: additionalDriver.licenseExpiry ? new Date(additionalDriver.licenseExpiry).toISOString() : undefined,
licenseIssuedAt: additionalDriver.licenseIssuedAt ? new Date(additionalDriver.licenseIssuedAt).toISOString() : undefined,
dateOfBirth: additionalDriver.dateOfBirth ? new Date(additionalDriver.dateOfBirth).toISOString() : undefined,
nationality: bilingualPrimary(additionalDriver.nationality).trim() || undefined,
nationalityAr: additionalDriver.nationality.ar.trim() || undefined,
}] : [],
notes: notes || undefined,
}),
})
router.push(`/reservations/${created.id}`)
} catch (err: any) {
setError(err.message)
} finally {
setSaving(false)
}
}
return (
<div className="space-y-6 max-w-3xl">
<div className="rdg-page-heading flex items-start justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div>
</div>
{error ? <div className="card p-4 text-sm text-red-700">{error}</div> : null}
<div className="card p-6 space-y-4">
{loading ? (
<p className="text-sm text-slate-500">Loading</p>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.customer} <span className="text-red-600">*</span></span>
<input
value={customerSearch}
onChange={(e) => setCustomerSearch(e.target.value)}
placeholder={copy.searchCustomer}
className="input-field mb-2"
/>
<select value={customerId} onChange={(e) => setCustomerId(e.target.value)} className="input-field">
<option value="">{copy.selectCustomer}</option>
{filteredCustomers.map((c) => (
<option key={c.id} value={c.id}>{c.firstName} {c.lastName} · {c.email}</option>
))}
</select>
<button
type="button"
className="text-xs font-semibold text-blue-700 hover:underline mt-1"
onClick={() => setShowAddCustomer((v) => !v)}
>
{copy.addCustomer}
</button>
{showAddCustomer ? (
<div className="mt-2 rounded-lg border border-slate-200 p-3 space-y-2 bg-slate-50">
<p className="text-xs font-semibold text-slate-700">{copy.addCustomerTitle}</p>
<BilingualInput label={copy.firstName} required value={newCustomerFirstName} onChange={setNewCustomerFirstName} />
<BilingualInput label={copy.lastName} required value={newCustomerLastName} onChange={setNewCustomerLastName} />
<div className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.email} <span className="text-red-600">*</span></span>
<input value={newCustomerEmail} onChange={(e) => setNewCustomerEmail(e.target.value)} className="input-field" />
</div>
<div className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.phone} <span className="text-red-600">*</span></span>
<input required value={newCustomerPhone} onChange={(e) => setNewCustomerPhone(e.target.value)} className="input-field" />
</div>
<div className="flex justify-end">
<button type="button" className="btn-secondary" onClick={addCustomer} disabled={savingCustomer}>
{savingCustomer ? copy.savingCustomer : copy.saveCustomer}
</button>
</div>
</div>
) : null}
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.vehicle} <span className="text-red-600">*</span></span>
<select value={vehicleId} onChange={(e) => setVehicleId(e.target.value)} className="input-field">
<option value="">{copy.selectVehicle}</option>
{availableVehicles.map((v) => (
<option key={v.id} value={v.id}>{v.make} {v.model} · {v.licensePlate}</option>
))}
</select>
{vehicles.length === 0 ? (
<span className="text-xs text-orange-700">{copy.noVehicles}</span>
) : availableVehicles.length === 0 ? (
<span className="text-xs text-orange-700">
{language === 'fr'
? 'Des véhicules existent dans la flotte, mais aucun nest marqué Disponible.'
: language === 'ar'
? 'توجد مركبات في الأسطول، لكن لا توجد مركبة بحالة متاحة.'
: 'Vehicles exist in fleet, but none are marked Available.'}
</span>
) : null}
</label>
</div>
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
<p className="text-sm font-semibold text-slate-900">{copy.renterIdentity}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.dateOfBirth} <span className="text-red-600">*</span></span>
<input type="date" value={customerDateOfBirth} onChange={(e) => setCustomerDateOfBirth(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.nationality} <span className="text-red-600">*</span></span>
<input value={customerNationality} onChange={(e) => setCustomerNationality(e.target.value)} className="input-field" />
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.identityDocumentNumber} <span className="text-red-600">*</span></span>
<input value={customerIdentityDocumentNumber} onChange={(e) => setCustomerIdentityDocumentNumber(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.internationalLicenseNumber}</span>
<input value={customerInternationalLicenseNumber} onChange={(e) => setCustomerInternationalLicenseNumber(e.target.value)} className="input-field" />
</label>
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.fullAddress} <span className="text-red-600">*</span></span>
<textarea value={customerFullAddress} onChange={(e) => setCustomerFullAddress(e.target.value)} className="input-field min-h-[88px]" />
</label>
</div>
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
<p className="text-sm font-semibold text-slate-900">{copy.driverLicenseInfo}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.driverLicense} <span className="text-red-600">*</span></span>
<input value={driverLicense} onChange={(e) => setDriverLicense(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseCountry} <span className="text-red-600">*</span></span>
<input value={licenseCountry} onChange={(e) => setLicenseCountry(e.target.value)} className="input-field" />
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseIssuedAt} <span className="text-red-600">*</span></span>
<input type="date" value={licenseIssuedAt} onChange={(e) => setLicenseIssuedAt(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseExpiry} <span className="text-red-600">*</span></span>
<input type="date" value={licenseExpiry} onChange={(e) => setLicenseExpiry(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseCategory} <span className="text-red-600">*</span></span>
<input value={licenseCategory} onChange={(e) => setLicenseCategory(e.target.value)} className="input-field" />
</label>
</div>
<div className="space-y-2">
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.licenseImage}</span>
<input
type="file"
accept="image/*"
onChange={(e) => setLicenseImageFile(e.target.files?.[0] ?? null)}
className="input-field"
/>
</label>
<p className="text-xs text-slate-500">{copy.licenseImageHint}</p>
{licenseImageFile ? (
<p className="text-xs font-medium text-slate-700">{copy.licenseImageSelected}: {licenseImageFile.name}</p>
) : licenseImageUrl ? (
<p className="text-xs font-medium text-slate-700">{copy.licenseImageCurrent}</p>
) : (
<p className="text-xs text-slate-500">{copy.noLicenseImage}</p>
)}
{licenseImagePreviewUrl || licenseImageUrl ? (
<img
src={licenseImagePreviewUrl ?? licenseImageUrl ?? ''}
alt="Driver license preview"
className="h-40 w-full max-w-sm rounded-xl border border-slate-200 object-cover"
/>
) : null}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.startDate} <span className="text-red-600">*</span></span>
<input type="datetime-local" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.endDate} <span className="text-red-600">*</span></span>
<input type="datetime-local" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="input-field" />
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.pickup}</span>
<input value={pickupLocation} onChange={(e) => setPickupLocation(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.return}</span>
<input value={returnLocation} onChange={(e) => setReturnLocation(e.target.value)} className="input-field" />
</label>
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.deposit}</span>
<input type="number" min={0} value={depositAmount} onChange={(e) => setDepositAmount(e.target.value)} className="input-field" />
</label>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.paymentMode}</span>
<select value={paymentMode} onChange={(e) => setPaymentMode(e.target.value)} className="input-field">
{(['CASH', 'CARD', 'BANK_TRANSFER', 'AMANPAY', 'PAYPAL'] as const).map((mode) => (
<option key={mode} value={mode}>{copy.paymentModes[mode]}</option>
))}
</select>
</label>
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={includeAdditionalDriver}
onChange={(e) => setIncludeAdditionalDriver(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-600"
/>
<span className="text-sm font-medium text-slate-900">{copy.addAdditionalDriver}</span>
</label>
{includeAdditionalDriver ? (
<div className="space-y-4">
<p className="text-sm font-semibold text-slate-900">{copy.additionalDriverInfo}</p>
<BilingualInput label={copy.firstName} required value={additionalDriver.firstName} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, firstName: v }))} />
<BilingualInput label={copy.lastName} required value={additionalDriver.lastName} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, lastName: v }))} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.email}</span>
<input
value={additionalDriver.email}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, email: e.target.value }))}
className="input-field"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.phone}</span>
<input
value={additionalDriver.phone}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, phone: e.target.value }))}
className="input-field"
/>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.driverLicense} <span className="text-red-600">*</span></span>
<input
value={additionalDriver.driverLicense}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, driverLicense: e.target.value }))}
className="input-field"
/>
</label>
<div className="space-y-1">
<BilingualInput label={copy.nationality} value={additionalDriver.nationality} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, nationality: v }))} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.dateOfBirth}</span>
<input
type="date"
value={additionalDriver.dateOfBirth}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, dateOfBirth: e.target.value }))}
className="input-field"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseIssuedAt}</span>
<input
type="date"
value={additionalDriver.licenseIssuedAt}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, licenseIssuedAt: e.target.value }))}
className="input-field"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseExpiry}</span>
<input
type="date"
value={additionalDriver.licenseExpiry}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, licenseExpiry: e.target.value }))}
className="input-field"
/>
</label>
</div>
</div>
) : null}
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.notes}</span>
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} className="input-field min-h-[96px]" placeholder={copy.notesPlaceholder} />
</label>
<div className="pt-2 flex items-center justify-end gap-3">
<button type="button" className="btn-secondary" onClick={() => router.push('/reservations')}>
{copy.cancel}
</button>
<button type="button" className="btn-primary" onClick={submit} disabled={saving || !canSubmit}>
{saving ? copy.creating : copy.create}
</button>
</div>
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,196 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface ReservationRow {
id: string
status: string
source: string
startDate: string
endDate: string
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 }
}
interface VehicleRow {
id: string
}
export default function ReservationsPage() {
const { dict, language } = useDashboardI18n()
const router = useRouter()
const r = dict.reservations
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [rows, setRows] = useState<ReservationRow[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [checkingNewReservation, setCheckingNewReservation] = useState(false)
const [showNoVehiclesModal, setShowNoVehiclesModal] = useState(false)
useEffect(() => {
apiFetch<ReservationRow[]>('/reservations?pageSize=100')
.then((result) => setRows(result ?? []))
.catch((err) => setLoadError(err.message))
}, [])
const formatDate = (iso: string) =>
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'
}
const noVehiclesMessage =
language === 'fr'
? 'Ajoutez au moins un véhicule à votre flotte avant de créer une réservation.'
: language === 'ar'
? 'أضف مركبة واحدة على الأقل إلى الأسطول قبل إنشاء حجز.'
: 'Add at least one vehicle to your fleet before creating a reservation.'
async function handleNewReservationClick() {
setActionError(null)
setCheckingNewReservation(true)
try {
const vehicles = await apiFetch<VehicleRow[]>('/vehicles?pageSize=1')
if (!vehicles || vehicles.length === 0) {
setShowNoVehiclesModal(true)
return
}
router.push('/reservations/new')
} catch (err: any) {
setActionError(err.message ?? (
language === 'fr'
? 'Impossible de vérifier la flotte avant de créer la réservation.'
: language === 'ar'
? 'تعذر التحقق من الأسطول قبل إنشاء الحجز.'
: 'Failed to verify fleet before creating the reservation.'
))
} finally {
setCheckingNewReservation(false)
}
}
function handleAddCar() {
setShowNoVehiclesModal(false)
router.push('/fleet?modal=add-car')
}
return (
<div className="space-y-6">
<div className="rdg-page-heading flex items-start justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-slate-900">{r.heading}</h2>
<p className="text-sm text-slate-500 mt-1">{r.subtitle}</p>
</div>
<button
type="button"
className="btn-primary whitespace-nowrap"
onClick={handleNewReservationClick}
disabled={checkingNewReservation}
>
{checkingNewReservation
? language === 'fr'
? 'Vérification…'
: language === 'ar'
? 'جارٍ التحقق…'
: 'Checking…'
: language === 'fr'
? 'Réserver une voiture'
: language === 'ar'
? 'حجز سيارة'
: 'Book car'}
</button>
</div>
{actionError ? <div className="card p-4 text-sm text-red-600">{actionError}</div> : null}
<div className="card overflow-hidden">
{loadError ? (
<div className="p-8 text-sm text-red-600">{loadError}</div>
) : (
<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">{r.colCustomer}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colVehicle}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colDates}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colSource}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colStatus}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colTotal}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rows.map((row) => (
<tr key={row.id}>
<td className="px-6 py-4">
<Link href={`/reservations/${row.id}`} className="text-sm font-semibold text-slate-900 hover:text-blue-700">
{row.customer.firstName} {row.customer.lastName}
</Link>
<p className="text-xs text-slate-500">{row.customer.email}</p>
<Link href={`/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>
<td className="px-6 py-4 text-sm text-slate-500">{formatDate(row.startDate)} - {formatDateYear(row.endDate)}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.source}</td>
<td className="px-6 py-4"><span className="badge-blue">{row.status}</span></td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{r.noReservations}</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
{showNoVehiclesModal ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/40 p-4"
onClick={(event) => {
if (event.target === event.currentTarget) setShowNoVehiclesModal(false)
}}
>
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<h3 className="text-lg font-semibold text-slate-900">
{language === 'fr' ? 'Ajouter un véhicule' : language === 'ar' ? 'أضف مركبة' : 'Add a car'}
</h3>
<p className="mt-2 text-sm text-slate-600">{noVehiclesMessage}</p>
<div className="mt-6 flex gap-3">
<button type="button" className="btn-primary flex-1" onClick={handleAddCar}>
{language === 'fr' ? 'Ajouter une voiture' : language === 'ar' ? 'إضافة مركبة' : 'Add car'}
</button>
<button type="button" className="btn-secondary flex-1" onClick={() => setShowNoVehiclesModal(false)}>
{language === 'fr' ? 'Annuler' : language === 'ar' ? 'إلغاء' : 'Cancel'}
</button>
</div>
</div>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,422 @@
'use client'
import { useEffect, useState } from 'react'
import { Star, MessageSquare, AlertTriangle } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
// ─── i18n ─────────────────────────────────────────────────────────────────────
const copy = {
en: {
heading: 'Reviews',
subtitle: 'Customer feedback for your company',
totalReviews: 'Total Reviews',
avgRating: 'Average Rating',
fiveStars: '5-Star Reviews',
negative: 'Negative Reviews',
filterRating: 'Filter by rating',
allRatings: 'All ratings',
filterDate: 'Date range',
last7: 'Last 7 days',
last30: 'Last 30 days',
last90: 'Last 90 days',
allTime: 'All time',
customer: 'Customer',
vehicle: 'Vehicle',
date: 'Date',
rating: 'Rating',
comment: 'Comment',
reply: 'Reply',
replied: 'Replied',
noReply: 'No reply yet',
saveReply: 'Save reply',
cancel: 'Cancel',
saving: 'Saving…',
noReviews: 'No reviews yet',
newComplaint: 'New complaint',
replyPlaceholder: 'Write your reply…',
errorLoad: 'Failed to load reviews',
errorReply: 'Failed to save reply',
stars: (n: number) => n === 1 ? '1 star' : `${n} stars`,
},
fr: {
heading: 'Avis',
subtitle: 'Retours clients pour votre entreprise',
totalReviews: 'Total des avis',
avgRating: 'Note moyenne',
fiveStars: 'Avis 5 étoiles',
negative: 'Avis négatifs',
filterRating: 'Filtrer par note',
allRatings: 'Toutes les notes',
filterDate: 'Période',
last7: '7 derniers jours',
last30: '30 derniers jours',
last90: '90 derniers jours',
allTime: 'Tout le temps',
customer: 'Client',
vehicle: 'Véhicule',
date: 'Date',
rating: 'Note',
comment: 'Commentaire',
reply: 'Répondre',
replied: 'Répondu',
noReply: 'Pas encore de réponse',
saveReply: 'Enregistrer',
cancel: 'Annuler',
saving: 'Enregistrement…',
noReviews: 'Aucun avis pour l\'instant',
newComplaint: 'Nouvelle plainte',
replyPlaceholder: 'Rédigez votre réponse…',
errorLoad: 'Échec du chargement des avis',
errorReply: 'Échec de l\'enregistrement de la réponse',
stars: (n: number) => n === 1 ? '1 étoile' : `${n} étoiles`,
},
ar: {
heading: 'التقييمات',
subtitle: 'آراء العملاء لشركتك',
totalReviews: 'إجمالي التقييمات',
avgRating: 'متوسط التقييم',
fiveStars: 'تقييمات 5 نجوم',
negative: 'تقييمات سلبية',
filterRating: 'تصفية حسب التقييم',
allRatings: 'جميع التقييمات',
filterDate: 'النطاق الزمني',
last7: 'آخر 7 أيام',
last30: 'آخر 30 يوماً',
last90: 'آخر 90 يوماً',
allTime: 'كل الوقت',
customer: 'العميل',
vehicle: 'المركبة',
date: 'التاريخ',
rating: 'التقييم',
comment: 'تعليق',
reply: 'رد',
replied: 'تم الرد',
noReply: 'لم يتم الرد بعد',
saveReply: 'حفظ الرد',
cancel: 'إلغاء',
saving: 'جار الحفظ…',
noReviews: 'لا توجد تقييمات بعد',
newComplaint: 'شكوى جديدة',
replyPlaceholder: 'اكتب ردك هنا…',
errorLoad: 'فشل تحميل التقييمات',
errorReply: 'فشل حفظ الرد',
stars: (n: number) => `${n} نجوم`,
},
} as const
// ─── Types ─────────────────────────────────────────────────────────────────────
interface ReviewReservation {
id: string
startDate: string
endDate: string
customer: { id: string; firstName: string; lastName: string; email: string }
vehicle: { make: string; model: string; year: number }
}
interface Review {
id: string
reservationId: string
overallRating: number
vehicleRating: number | null
serviceRating: number | null
comment: string | null
companyReply: string | null
companyRepliedAt: string | null
isPublished: boolean
createdAt: string
reservation: ReviewReservation
}
interface ReviewsResponse {
data: Review[]
meta: { total: number; page: number; pageSize: number; totalPages: number }
}
interface Stats {
total: number
averageOverall: number
averageVehicle: number
averageService: number
byRating: Record<string, number>
}
// ─── Star display ──────────────────────────────────────────────────────────────
function StarDisplay({ rating, size = 'sm' }: { rating: number; size?: 'sm' | 'md' }) {
const sz = size === 'md' ? 'h-5 w-5' : 'h-4 w-4'
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((s) => (
<Star
key={s}
className={`${sz} ${s <= rating ? 'fill-amber-400 text-amber-400' : 'text-stone-300 dark:text-blue-900'}`}
/>
))}
</div>
)
}
// ─── Main page ─────────────────────────────────────────────────────────────────
export default function ReviewsPage() {
const { language } = useDashboardI18n()
const t = copy[language as keyof typeof copy] ?? copy.en
const [reviews, setReviews] = useState<Review[]>([])
const [meta, setMeta] = useState({ total: 0, page: 1, pageSize: 20, totalPages: 1 })
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filterRating, setFilterRating] = useState<string>('')
const [filterDays, setFilterDays] = useState<string>('')
const [page, setPage] = useState(1)
const [replyingId, setReplyingId] = useState<string | null>(null)
const [replyText, setReplyText] = useState('')
const [replySaving, setReplySaving] = useState(false)
async function loadData() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({ page: String(page), pageSize: '20' })
if (filterRating) params.set('rating', filterRating)
const [reviewsData, statsData] = await Promise.all([
apiFetch<ReviewsResponse>(`/reviews?${params}`),
apiFetch<Stats>('/reviews/stats'),
])
let data = reviewsData.data ?? []
if (filterDays) {
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - Number(filterDays))
data = data.filter((r) => new Date(r.createdAt) >= cutoff)
}
setReviews(data)
setMeta(reviewsData.meta ?? { total: 0, page: 1, pageSize: 20, totalPages: 1 })
setStats(statsData)
} catch {
setError(t.errorLoad)
} finally {
setLoading(false)
}
}
useEffect(() => { loadData() }, [page, filterRating, filterDays])
async function handleSaveReply(reviewId: string) {
if (!replyText.trim()) return
setReplySaving(true)
try {
await apiFetch(`/reviews/${reviewId}/reply`, {
method: 'PATCH',
body: JSON.stringify({ companyReply: replyText.trim() }),
})
setReplyingId(null)
setReplyText('')
await loadData()
} catch {
alert(t.errorReply)
} finally {
setReplySaving(false)
}
}
function startReply(review: Review) {
setReplyingId(review.id)
setReplyText(review.companyReply ?? '')
}
const isRtl = language === 'ar'
return (
<div className={`space-y-6 ${isRtl ? 'rtl' : 'ltr'}`}>
{/* Header */}
<div className="rdg-page-heading">
<h1 className="text-2xl font-bold text-blue-950 dark:text-white">{t.heading}</h1>
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">{t.subtitle}</p>
</div>
{/* Stats cards */}
{stats && (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div className="card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.totalReviews}</p>
<p className="mt-2 text-3xl font-bold text-blue-950 dark:text-white">{stats.total}</p>
</div>
<div className="card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.avgRating}</p>
<div className="mt-2 flex items-center gap-2">
<p className="text-3xl font-bold text-blue-950 dark:text-white">{stats.averageOverall.toFixed(1)}</p>
<StarDisplay rating={Math.round(stats.averageOverall)} size="md" />
</div>
</div>
<div className="card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.fiveStars}</p>
<p className="mt-2 text-3xl font-bold text-emerald-600 dark:text-emerald-400">{stats.byRating['5'] ?? 0}</p>
</div>
<div className="card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.negative}</p>
<p className="mt-2 text-3xl font-bold text-red-600 dark:text-red-400">
{(stats.byRating['1'] ?? 0) + (stats.byRating['2'] ?? 0)}
</p>
</div>
</div>
)}
{/* Filters */}
<div className="card p-4">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-stone-700 dark:text-slate-300">{t.filterRating}</label>
<select
value={filterRating}
onChange={(e) => { setFilterRating(e.target.value); setPage(1) }}
className="input-field w-auto"
>
<option value="">{t.allRatings}</option>
{[5, 4, 3, 2, 1].map((r) => (
<option key={r} value={String(r)}>{t.stars(r)}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-stone-700 dark:text-slate-300">{t.filterDate}</label>
<select
value={filterDays}
onChange={(e) => { setFilterDays(e.target.value); setPage(1) }}
className="input-field w-auto"
>
<option value="">{t.allTime}</option>
<option value="7">{t.last7}</option>
<option value="30">{t.last30}</option>
<option value="90">{t.last90}</option>
</select>
</div>
</div>
</div>
{/* Reviews list */}
{loading ? (
<div className="card p-10 text-center text-stone-400 dark:text-slate-500">Loading</div>
) : error ? (
<div className="card p-10 text-center text-red-500">{error}</div>
) : reviews.length === 0 ? (
<div className="card p-10 text-center text-stone-400 dark:text-slate-500">{t.noReviews}</div>
) : (
<div className="space-y-4">
{reviews.map((review) => {
const customer = review.reservation?.customer
const vehicle = review.reservation?.vehicle
const vehicleLabel = vehicle ? `${vehicle.year} ${vehicle.make} ${vehicle.model}` : '—'
const customerName = customer ? `${customer.firstName} ${customer.lastName}` : '—'
const isReplying = replyingId === review.id
const isNegative = review.overallRating <= 2
return (
<div key={review.id} className={`card p-5 ${isNegative ? 'border-red-200 dark:border-red-900/40' : ''}`}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-3">
<span className="font-semibold text-blue-950 dark:text-white">{customerName}</span>
<span className="text-sm text-stone-400 dark:text-slate-500">{vehicleLabel}</span>
<span className="text-xs text-stone-400 dark:text-slate-500">
{new Date(review.createdAt).toLocaleDateString()}
</span>
</div>
<StarDisplay rating={review.overallRating} />
{review.comment && (
<p className="mt-2 line-clamp-3 text-sm text-stone-600 dark:text-slate-300">{review.comment}</p>
)}
{review.companyReply && !isReplying && (
<div className="mt-3 rounded-2xl border border-blue-100 bg-blue-50 p-3 dark:border-blue-900/40 dark:bg-blue-950/30">
<div className="mb-1 flex items-center gap-2">
<MessageSquare className="h-3.5 w-3.5 text-blue-500" />
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">{t.replied}</span>
</div>
<p className="text-sm text-stone-600 dark:text-slate-300">{review.companyReply}</p>
</div>
)}
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<button
onClick={() => startReply(review)}
className="btn-secondary text-xs"
>
<MessageSquare className="h-3.5 w-3.5" />
{review.companyReply ? t.replied : t.reply}
</button>
<a
href={`/complaints?reservationId=${review.reservation?.id ?? ''}`}
className="btn-secondary text-xs"
>
<AlertTriangle className="h-3.5 w-3.5" />
{t.newComplaint}
</a>
</div>
</div>
{/* Inline reply form */}
{isReplying && (
<div className="mt-4 space-y-3">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder={t.replyPlaceholder}
rows={3}
className="input-field resize-none"
/>
<div className="flex gap-2">
<button
onClick={() => handleSaveReply(review.id)}
disabled={replySaving || !replyText.trim()}
className="btn-primary text-sm"
>
{replySaving ? t.saving : t.saveReply}
</button>
<button
onClick={() => { setReplyingId(null); setReplyText('') }}
className="btn-secondary text-sm"
>
{t.cancel}
</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
{/* Pagination */}
{meta.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="btn-secondary text-sm"
>
</button>
<span className="text-sm text-stone-600 dark:text-slate-400">
{page} / {meta.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(meta.totalPages, p + 1))}
disabled={page >= meta.totalPages}
className="btn-secondary text-sm"
>
</button>
</div>
)}
</div>
)
}
@@ -0,0 +1,830 @@
'use client'
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface BrandSettings {
displayName: string
tagline: string | null
primaryColor: string
accentColor?: string | null
publicEmail: string | null
publicPhone: string | null
publicAddress?: string | null
publicCity: string | null
publicCountry?: string | null
subdomain: string
logoUrl?: string | null
heroImageUrl?: string | null
websiteUrl?: string | null
whatsappNumber?: string | null
paypalEmail: string | null
amanpayMerchantId: string | null
amanpaySecretKey?: string | null
paypalMerchantId?: string | null
customDomain?: string | null
customDomainVerified?: boolean
defaultLocale?: string
defaultCurrency?: string
isListedOnMarketplace: boolean
}
interface ContractSettings {
fuelPolicyType: string
fuelPolicyNote: string | null
additionalDriverCharge: 'FREE' | 'PER_DAY' | 'FLAT'
additionalDriverDailyRate: number
additionalDriverFlatRate: number
damagePolicy: string
}
interface InsurancePolicy {
id: string
name: string
type: string
chargeType: string
chargeValue: number
isRequired: boolean
isActive: boolean
}
interface PricingRule {
id: string
name: string
type: string
condition: string
conditionValue: number
adjustmentType: string
adjustmentValue: number
isActive: boolean
}
interface AccountingSettings {
reportingPeriod: string
accountantEmail: string | null
accountantName: string | null
autoSendReport: boolean
reportFormat: string
}
const emptyInsurance = { name: '', type: 'BASIC', chargeType: 'PER_DAY', chargeValue: 0, isRequired: false, isActive: true }
const emptyRule = { name: '', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 0, isActive: true }
export default function SettingsPage() {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Advanced settings',
subtitle: 'Manage insurance, additional-driver rules, pricing adjustments, and reporting defaults.',
save: 'Saving…',
saveBrand: 'Save brand',
brandProfile: 'Brand and public profile',
brandProfileDesc: 'Control how your company appears on the marketplace and public booking site.',
payments: 'Rental payment methods',
paymentsDesc: 'Configure how renters pay your company on the public booking site.',
savePayments: 'Save payments',
customDomain: 'Custom domain',
customDomainDesc: 'Point your own domain to the branded booking site.',
saveDomain: 'Save domain',
removeDomain: 'Remove custom domain',
contractPolicies: 'Contract and driver policies',
savePolicies: 'Save policies',
insurancePolicies: 'Insurance policies',
pricingRules: 'Pricing rules',
accounting: 'Accounting and exports',
saveAccounting: 'Save accounting',
displayName: 'Display name',
tagline: 'Tagline',
publicEmail: 'Public email',
publicPhone: 'Public phone',
city: 'City',
country: 'Country',
websiteUrl: 'Website URL',
contractLanguage: 'Contract language',
whatsapp: 'WhatsApp number',
brandColor: 'Brand color',
listedMarketplace: 'Listed on marketplace',
logoUpload: 'Logo upload',
heroUpload: 'Hero image upload',
uploading: 'Uploading…',
logoUploaded: 'Logo uploaded',
noLogo: 'No logo uploaded yet',
heroUploaded: 'Hero image uploaded',
noHero: 'No hero image uploaded yet',
subdomain: 'Subdomain',
customDomainLabel: 'Custom domain',
domainPlaceholder: 'cars.example.com',
status: 'Status',
verified: 'Verified',
pendingDns: 'Pending DNS verification',
notConfigured: 'Not configured',
fuelPolicyType: 'Fuel policy type',
additionalDriverCharge: 'Additional driver charge',
dailyDriverRate: 'Daily driver rate',
flatDriverRate: 'Flat driver rate',
fuelPolicyNote: 'Fuel policy note',
damagePolicy: 'Damage policy',
active: 'Active',
inactive: 'Inactive',
noInsurance: 'No insurance policies configured yet.',
policyName: 'Policy name',
chargeValue: 'Charge value',
required: 'Required',
addPolicy: 'Add policy',
noRules: 'No pricing rules configured yet.',
ruleName: 'Rule name',
addRule: 'Add rule',
reportingPeriod: 'Reporting period',
reportFormat: 'Report format',
accountantName: 'Accountant name',
accountantEmail: 'Accountant email',
autoSend: 'Auto-send reports to the accountant',
amanpayMerchantId: 'AmanPay merchant ID',
amanpaySecretKey: 'AmanPay secret key',
paypalEmail: 'PayPal business email',
noPaymentConfigured: 'If no payment method is configured, renters can still submit reservation requests and pay on pickup.',
fuelPolicyLabels: { FULL_TO_FULL: 'Full to full', FULL_TO_EMPTY: 'Full to empty', SAME_TO_SAME: 'Same to same', PREPAID: 'Prepaid', FREE: 'Included' } as Record<string, string>,
driverChargeLabels: { FREE: 'Free', PER_DAY: 'Per day', FLAT: 'Flat' } as Record<string, string>,
reportingPeriodLabels: { WEEKLY: 'Weekly', MONTHLY: 'Monthly', QUARTERLY: 'Quarterly', ANNUAL: 'Annual' } as Record<string, string>,
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'Both' } as Record<string, string>,
},
fr: {
title: 'Paramètres avancés',
subtitle: 'Gérez les assurances, règles conducteurs, ajustements tarifaires et paramètres de reporting.',
save: 'Enregistrement…',
saveBrand: 'Enregistrer la marque',
brandProfile: 'Marque et profil public',
brandProfileDesc: 'Contrôlez laffichage de votre entreprise sur la marketplace et le site de réservation.',
payments: 'Méthodes de paiement location',
paymentsDesc: 'Configurez comment les clients paient votre entreprise.',
savePayments: 'Enregistrer les paiements',
customDomain: 'Domaine personnalisé',
customDomainDesc: 'Pointez votre propre domaine vers le site de réservation.',
saveDomain: 'Enregistrer le domaine',
removeDomain: 'Supprimer le domaine',
contractPolicies: 'Contrat et politiques conducteur',
savePolicies: 'Enregistrer les politiques',
insurancePolicies: 'Polices dassurance',
pricingRules: 'Règles tarifaires',
accounting: 'Comptabilité et exports',
saveAccounting: 'Enregistrer la comptabilité',
displayName: 'Nom affiché',
tagline: 'Slogan',
publicEmail: 'E-mail public',
publicPhone: 'Téléphone public',
city: 'Ville',
country: 'Pays',
websiteUrl: 'URL du site',
contractLanguage: 'Langue du contrat',
whatsapp: 'Numéro WhatsApp',
brandColor: 'Couleur de marque',
listedMarketplace: 'Publié sur la marketplace',
logoUpload: 'Logo',
heroUpload: 'Image principale',
uploading: 'Téléversement…',
logoUploaded: 'Logo téléversé',
noLogo: 'Aucun logo téléversé',
heroUploaded: 'Image principale téléversée',
noHero: 'Aucune image principale téléversée',
subdomain: 'Sous-domaine',
customDomainLabel: 'Domaine personnalisé',
domainPlaceholder: 'voitures.exemple.com',
status: 'Statut',
verified: 'Vérifié',
pendingDns: 'Vérification DNS en attente',
notConfigured: 'Non configuré',
fuelPolicyType: 'Type de politique carburant',
additionalDriverCharge: 'Supplément conducteur additionnel',
dailyDriverRate: 'Tarif conducteur/jour',
flatDriverRate: 'Tarif conducteur fixe',
fuelPolicyNote: 'Note sur la politique carburant',
damagePolicy: 'Politique dommages',
active: 'Actif',
inactive: 'Inactif',
noInsurance: 'Aucune police dassurance configurée.',
policyName: 'Nom de la police',
chargeValue: 'Valeur de facturation',
required: 'Obligatoire',
addPolicy: 'Ajouter la police',
noRules: 'Aucune règle tarifaire configurée.',
ruleName: 'Nom de la règle',
addRule: 'Ajouter la règle',
reportingPeriod: 'Période de reporting',
reportFormat: 'Format de rapport',
accountantName: 'Nom du comptable',
accountantEmail: 'E-mail du comptable',
autoSend: 'Envoyer automatiquement les rapports au comptable',
amanpayMerchantId: 'ID marchand AmanPay',
amanpaySecretKey: 'Clé secrète AmanPay',
paypalEmail: 'Email business PayPal',
noPaymentConfigured: 'Si aucun moyen de paiement nest configuré, les clients peuvent envoyer une demande puis payer à la prise du véhicule.',
fuelPolicyLabels: { FULL_TO_FULL: 'Plein à plein', FULL_TO_EMPTY: 'Plein à vide', SAME_TO_SAME: 'Même niveau', PREPAID: 'Prépayé', FREE: 'Inclus' } as Record<string, string>,
driverChargeLabels: { FREE: 'Gratuit', PER_DAY: 'Par jour', FLAT: 'Forfait' } as Record<string, string>,
reportingPeriodLabels: { WEEKLY: 'Hebdomadaire', MONTHLY: 'Mensuel', QUARTERLY: 'Trimestriel', ANNUAL: 'Annuel' } as Record<string, string>,
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'Les deux' } as Record<string, string>,
},
ar: {
title: 'الإعدادات المتقدمة',
subtitle: 'إدارة التأمين وقواعد السائق الإضافي وتعديلات التسعير وإعدادات التقارير.',
save: 'جارٍ الحفظ…',
saveBrand: 'حفظ العلامة',
brandProfile: 'العلامة والملف العام',
brandProfileDesc: 'تحكم في ظهور شركتك في السوق وموقع الحجز العام.',
payments: 'طرق دفع الإيجار',
paymentsDesc: 'تهيئة طريقة دفع العملاء لشركتك في موقع الحجز.',
savePayments: 'حفظ الدفع',
customDomain: 'نطاق مخصص',
customDomainDesc: 'ربط نطاقك الخاص بموقع الحجز.',
saveDomain: 'حفظ النطاق',
removeDomain: 'إزالة النطاق المخصص',
contractPolicies: 'العقد وسياسات السائق',
savePolicies: 'حفظ السياسات',
insurancePolicies: 'سياسات التأمين',
pricingRules: 'قواعد التسعير',
accounting: 'المحاسبة والتصدير',
saveAccounting: 'حفظ المحاسبة',
displayName: 'اسم العرض',
tagline: 'الشعار',
publicEmail: 'البريد الإلكتروني العام',
publicPhone: 'الهاتف العام',
city: 'المدينة',
country: 'الدولة',
websiteUrl: 'رابط الموقع',
contractLanguage: 'لغة العقد',
whatsapp: 'رقم واتساب',
brandColor: 'لون العلامة',
listedMarketplace: 'مدرج في السوق',
logoUpload: 'رفع الشعار',
heroUpload: 'رفع صورة الواجهة',
uploading: 'جارٍ الرفع…',
logoUploaded: 'تم رفع الشعار',
noLogo: 'لم يتم رفع شعار بعد',
heroUploaded: 'تم رفع صورة الواجهة',
noHero: 'لم يتم رفع صورة واجهة بعد',
subdomain: 'النطاق الفرعي',
customDomainLabel: 'النطاق المخصص',
domainPlaceholder: 'cars.example.com',
status: 'الحالة',
verified: 'موثق',
pendingDns: 'بانتظار التحقق من DNS',
notConfigured: 'غير مُعدّ',
fuelPolicyType: 'نوع سياسة الوقود',
additionalDriverCharge: 'رسوم السائق الإضافي',
dailyDriverRate: 'تعرفة السائق اليومية',
flatDriverRate: 'تعرفة السائق الثابتة',
fuelPolicyNote: 'ملاحظة سياسة الوقود',
damagePolicy: 'سياسة الأضرار',
active: 'نشط',
inactive: 'غير نشط',
noInsurance: 'لا توجد سياسات تأمين مهيأة.',
policyName: 'اسم السياسة',
chargeValue: 'قيمة الرسوم',
required: 'إلزامي',
addPolicy: 'إضافة سياسة',
noRules: 'لا توجد قواعد تسعير مهيأة.',
ruleName: 'اسم القاعدة',
addRule: 'إضافة قاعدة',
reportingPeriod: 'فترة التقرير',
reportFormat: 'تنسيق التقرير',
accountantName: 'اسم المحاسب',
accountantEmail: 'بريد المحاسب',
autoSend: 'إرسال التقارير تلقائياً إلى المحاسب',
amanpayMerchantId: 'معرّف التاجر AmanPay',
amanpaySecretKey: 'المفتاح السري AmanPay',
paypalEmail: 'بريد أعمال PayPal',
noPaymentConfigured: 'إذا لم يتم إعداد وسيلة دفع، لا يزال بإمكان العملاء إرسال طلب حجز والدفع عند الاستلام.',
fuelPolicyLabels: { FULL_TO_FULL: 'ممتلئ إلى ممتلئ', FULL_TO_EMPTY: 'ممتلئ إلى فارغ', SAME_TO_SAME: 'نفس المستوى', PREPAID: 'مدفوع مسبقاً', FREE: 'مشمول' } as Record<string, string>,
driverChargeLabels: { FREE: 'مجاني', PER_DAY: 'يومي', FLAT: 'ثابت' } as Record<string, string>,
reportingPeriodLabels: { WEEKLY: 'أسبوعي', MONTHLY: 'شهري', QUARTERLY: 'ربع سنوي', ANNUAL: 'سنوي' } as Record<string, string>,
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'كلاهما' } as Record<string, string>,
},
}[language]
const [brand, setBrand] = useState<BrandSettings | null>(null)
const [contractSettings, setContractSettings] = useState<ContractSettings | null>(null)
const [insurancePolicies, setInsurancePolicies] = useState<InsurancePolicy[]>([])
const [pricingRules, setPricingRules] = useState<PricingRule[]>([])
const [accountingSettings, setAccountingSettings] = useState<AccountingSettings | null>(null)
const [newInsurance, setNewInsurance] = useState(emptyInsurance)
const [newRule, setNewRule] = useState(emptyRule)
const [customDomain, setCustomDomain] = useState('')
const [uploadingAsset, setUploadingAsset] = useState<'logo' | 'hero' | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
async function load() {
try {
const [brandData, contractData, insuranceData, ruleData, accountingData] = await Promise.all([
apiFetch<BrandSettings | null>('/companies/me/brand'),
apiFetch<ContractSettings | null>('/companies/me/contract-settings'),
apiFetch<InsurancePolicy[]>('/companies/me/insurance-policies'),
apiFetch<PricingRule[]>('/companies/me/pricing-rules'),
apiFetch<AccountingSettings | null>('/companies/me/accounting-settings'),
])
setBrand(brandData)
setCustomDomain(brandData?.customDomain ?? '')
setContractSettings(contractData ?? {
fuelPolicyType: 'FULL_TO_FULL',
fuelPolicyNote: '',
additionalDriverCharge: 'FREE',
additionalDriverDailyRate: 0,
additionalDriverFlatRate: 0,
damagePolicy: '',
})
setInsurancePolicies(insuranceData)
setPricingRules(ruleData)
setAccountingSettings(accountingData ?? {
reportingPeriod: 'MONTHLY',
accountantEmail: '',
accountantName: '',
autoSendReport: false,
reportFormat: 'CSV',
})
setError(null)
} catch (err: any) {
setError(err.message ?? 'Failed to load settings')
}
}
async function saveBrandSettings() {
if (!brand) return
setSaving(true)
setError(null)
try {
const updated = await apiFetch<BrandSettings>('/companies/me/brand', {
method: 'PATCH',
body: JSON.stringify({
displayName: brand.displayName,
tagline: brand.tagline || undefined,
primaryColor: brand.primaryColor,
accentColor: brand.accentColor || undefined,
publicEmail: brand.publicEmail || undefined,
publicPhone: brand.publicPhone || undefined,
publicAddress: brand.publicAddress || undefined,
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,
amanpaySecretKey: brand.amanpaySecretKey || undefined,
paypalMerchantId: brand.paypalMerchantId || undefined,
defaultCurrency: brand.defaultCurrency || undefined,
isListedOnMarketplace: brand.isListedOnMarketplace,
}),
})
setBrand(updated)
} catch (err: any) {
setError(err.message ?? 'Failed to save brand settings')
} finally {
setSaving(false)
}
}
async function uploadBrandAsset(kind: 'logo' | 'hero', file: File | null) {
if (!file) return
setUploadingAsset(kind)
setError(null)
try {
const formData = new FormData()
formData.append('file', file)
const updated = await apiFetch<BrandSettings>(`/companies/me/brand/${kind}`, {
method: 'POST',
body: formData,
})
setBrand(updated)
} catch (err: any) {
setError(err.message ?? `Failed to upload ${kind}`)
} finally {
setUploadingAsset(null)
}
}
async function saveCustomDomain() {
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/brand/custom-domain', {
method: 'POST',
body: JSON.stringify({ customDomain }),
})
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to save custom domain')
} finally {
setSaving(false)
}
}
async function removeCustomDomain() {
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/brand/custom-domain', {
method: 'DELETE',
})
setCustomDomain('')
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to remove custom domain')
} finally {
setSaving(false)
}
}
useEffect(() => {
load()
}, [])
async function saveContractSettings() {
if (!contractSettings) return
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/contract-settings', {
method: 'PATCH',
body: JSON.stringify(contractSettings),
})
} catch (err: any) {
setError(err.message ?? 'Failed to save contract settings')
} finally {
setSaving(false)
}
}
async function saveAccountingSettings() {
if (!accountingSettings) return
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/accounting-settings', {
method: 'PATCH',
body: JSON.stringify(accountingSettings),
})
} catch (err: any) {
setError(err.message ?? 'Failed to save accounting settings')
} finally {
setSaving(false)
}
}
async function createInsurance() {
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/insurance-policies', {
method: 'POST',
body: JSON.stringify(newInsurance),
})
setNewInsurance(emptyInsurance)
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to create insurance policy')
} finally {
setSaving(false)
}
}
async function toggleInsurance(policy: InsurancePolicy) {
try {
await apiFetch(`/companies/me/insurance-policies/${policy.id}`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !policy.isActive }),
})
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to update insurance policy')
}
}
async function createRule() {
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/pricing-rules', {
method: 'POST',
body: JSON.stringify(newRule),
})
setNewRule(emptyRule)
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to create pricing rule')
} finally {
setSaving(false)
}
}
async function toggleRule(rule: PricingRule) {
try {
await apiFetch(`/companies/me/pricing-rules/${rule.id}`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !rule.isActive }),
})
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to update pricing rule')
}
}
return (
<div className="space-y-6">
<div className="rdg-page-heading">
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
{brand && (
<div className="grid gap-6">
<div className="card p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">{copy.brandProfile}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.brandProfileDesc}</p>
</div>
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
{saving ? copy.save : copy.saveBrand}
</button>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.displayName}</label>
<input className="input-field" value={brand.displayName} onChange={(event) => setBrand((current) => current ? { ...current, displayName: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.tagline}</label>
<input className="input-field" value={brand.tagline ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, tagline: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.publicEmail}</label>
<input className="input-field" type="email" value={brand.publicEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicEmail: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.publicPhone}</label>
<input className="input-field" value={brand.publicPhone ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicPhone: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.city}</label>
<input className="input-field" value={brand.publicCity ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCity: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.country}</label>
<input className="input-field" value={brand.publicCountry ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCountry: event.target.value } : current)} />
</div>
<div>
<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)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.brandColor}</label>
<input className="input-field" value={brand.primaryColor} onChange={(event) => setBrand((current) => current ? { ...current, primaryColor: event.target.value } : current)} />
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={brand.isListedOnMarketplace} onChange={(event) => setBrand((current) => current ? { ...current, isListedOnMarketplace: event.target.checked } : current)} />
{copy.listedMarketplace}
</label>
</div>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">{copy.logoUpload}</span>
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('logo', event.target.files?.[0] ?? null)} className="input-field" />
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'logo' ? copy.uploading : brand.logoUrl ? copy.logoUploaded : copy.noLogo}</p>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">{copy.heroUpload}</span>
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('hero', event.target.files?.[0] ?? null)} className="input-field" />
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'hero' ? copy.uploading : brand.heroImageUrl ? copy.heroUploaded : copy.noHero}</p>
</label>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="card p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">{copy.payments}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.paymentsDesc}</p>
</div>
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
{saving ? copy.save : copy.savePayments}
</button>
</div>
<div className="mt-5 grid gap-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.amanpayMerchantId}</label>
<input className="input-field" value={brand.amanpayMerchantId ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpayMerchantId: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.amanpaySecretKey}</label>
<input className="input-field" type="password" value={brand.amanpaySecretKey ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpaySecretKey: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.paypalEmail}</label>
<input className="input-field" type="email" value={brand.paypalEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, paypalEmail: event.target.value } : current)} />
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
{copy.noPaymentConfigured}
</div>
</div>
</div>
<div className="card p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">{copy.customDomain}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.customDomainDesc}</p>
</div>
<button onClick={saveCustomDomain} disabled={saving || !customDomain.trim()} className="btn-primary">
{saving ? copy.save : copy.saveDomain}
</button>
</div>
<div className="mt-5 space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.subdomain}</label>
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">{brand.subdomain}.RentalDriveGo.com</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.customDomainLabel}</label>
<input className="input-field" placeholder={copy.domainPlaceholder} value={customDomain} onChange={(event) => setCustomDomain(event.target.value)} />
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
{copy.status}: {brand.customDomain ? (brand.customDomainVerified ? copy.verified : copy.pendingDns) : copy.notConfigured}
</div>
{brand.customDomain ? (
<button onClick={removeCustomDomain} disabled={saving} className="btn-secondary">
{copy.removeDomain}
</button>
) : null}
</div>
</div>
</div>
</div>
)}
{contractSettings && (
<div className="card p-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">{copy.contractPolicies}</h3>
<button onClick={saveContractSettings} disabled={saving} className="btn-primary">
{saving ? copy.save : copy.savePolicies}
</button>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.fuelPolicyType}</label>
<select className="input-field" value={contractSettings.fuelPolicyType} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyType: event.target.value } : current)}>
{['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE'].map((option) => <option key={option} value={option}>{copy.fuelPolicyLabels[option] ?? option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.additionalDriverCharge}</label>
<select className="input-field" value={contractSettings.additionalDriverCharge} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverCharge: event.target.value as ContractSettings['additionalDriverCharge'] } : current)}>
{['FREE', 'PER_DAY', 'FLAT'].map((option) => <option key={option} value={option}>{copy.driverChargeLabels[option] ?? option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.dailyDriverRate}</label>
<input type="number" className="input-field" value={contractSettings.additionalDriverDailyRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverDailyRate: Number(event.target.value) } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.flatDriverRate}</label>
<input type="number" className="input-field" value={contractSettings.additionalDriverFlatRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverFlatRate: Number(event.target.value) } : current)} />
</div>
<div className="lg:col-span-2">
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.fuelPolicyNote}</label>
<textarea className="input-field min-h-24" value={contractSettings.fuelPolicyNote ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyNote: event.target.value } : current)} />
</div>
<div className="lg:col-span-2">
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.damagePolicy}</label>
<textarea className="input-field min-h-24" value={contractSettings.damagePolicy ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, damagePolicy: event.target.value } : current)} />
</div>
</div>
</div>
)}
<div className="grid gap-6 xl:grid-cols-2">
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">{copy.insurancePolicies}</h3>
<div className="mt-5 space-y-3">
{insurancePolicies.map((policy) => (
<div key={policy.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
<div>
<p className="font-medium text-slate-900">{policy.name}</p>
<p className="text-slate-500">{policy.type} · {policy.chargeType} · {policy.chargeValue}</p>
</div>
<button onClick={() => toggleInsurance(policy)} className={policy.isActive ? 'badge-green' : 'badge-gray'}>
{policy.isActive ? copy.active : copy.inactive}
</button>
</div>
))}
{insurancePolicies.length === 0 && <div className="text-sm text-slate-400">{copy.noInsurance}</div>}
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<input className="input-field" placeholder={copy.policyName} value={newInsurance.name} onChange={(event) => setNewInsurance((current) => ({ ...current, name: event.target.value }))} />
<select className="input-field" value={newInsurance.type} onChange={(event) => setNewInsurance((current) => ({ ...current, type: event.target.value }))}>
{['BASIC', 'FULL', 'CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'ROADSIDE', 'PERSONAL', 'CUSTOM'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<select className="input-field" value={newInsurance.chargeType} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeType: event.target.value }))}>
{['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<input type="number" className="input-field" placeholder={copy.chargeValue} value={newInsurance.chargeValue} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeValue: Number(event.target.value) }))} />
</div>
<div className="mt-3 flex items-center gap-4 text-sm">
<label className="flex items-center gap-2"><input type="checkbox" checked={newInsurance.isRequired} onChange={(event) => setNewInsurance((current) => ({ ...current, isRequired: event.target.checked }))} /> {copy.required}</label>
<button onClick={createInsurance} disabled={saving} className="btn-primary">{saving ? copy.save : copy.addPolicy}</button>
</div>
</div>
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">{copy.pricingRules}</h3>
<div className="mt-5 space-y-3">
{pricingRules.map((rule) => (
<div key={rule.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
<div>
<p className="font-medium text-slate-900">{rule.name}</p>
<p className="text-slate-500">{rule.type} · {rule.condition} {rule.conditionValue} · {rule.adjustmentType} {rule.adjustmentValue}</p>
</div>
<button onClick={() => toggleRule(rule)} className={rule.isActive ? 'badge-green' : 'badge-gray'}>
{rule.isActive ? copy.active : copy.inactive}
</button>
</div>
))}
{pricingRules.length === 0 && <div className="text-sm text-slate-400">{copy.noRules}</div>}
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<input className="input-field" placeholder={copy.ruleName} value={newRule.name} onChange={(event) => setNewRule((current) => ({ ...current, name: event.target.value }))} />
<select className="input-field" value={newRule.type} onChange={(event) => setNewRule((current) => ({ ...current, type: event.target.value }))}>
{['SURCHARGE', 'DISCOUNT'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<select className="input-field" value={newRule.condition} onChange={(event) => setNewRule((current) => ({ ...current, condition: event.target.value }))}>
{['AGE_LESS_THAN', 'AGE_GREATER_THAN', 'LICENSE_YEARS_LESS_THAN', 'LICENSE_YEARS_GREATER_THAN'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<input type="number" className="input-field" value={newRule.conditionValue} onChange={(event) => setNewRule((current) => ({ ...current, conditionValue: Number(event.target.value) }))} />
<select className="input-field" value={newRule.adjustmentType} onChange={(event) => setNewRule((current) => ({ ...current, adjustmentType: event.target.value }))}>
{['PERCENTAGE', 'FLAT_PER_DAY', 'FLAT_TOTAL'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<input type="number" className="input-field" value={newRule.adjustmentValue} onChange={(event) => setNewRule((current) => ({ ...current, adjustmentValue: Number(event.target.value) }))} />
</div>
<div className="mt-3">
<button onClick={createRule} disabled={saving} className="btn-primary">{saving ? copy.save : copy.addRule}</button>
</div>
</div>
</div>
{accountingSettings && (
<div className="card p-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">{copy.accounting}</h3>
<button onClick={saveAccountingSettings} disabled={saving} className="btn-primary">
{saving ? copy.save : copy.saveAccounting}
</button>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.reportingPeriod}</label>
<select className="input-field" value={accountingSettings.reportingPeriod} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportingPeriod: event.target.value } : current)}>
{['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL'].map((option) => <option key={option} value={option}>{copy.reportingPeriodLabels[option] ?? option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.reportFormat}</label>
<select className="input-field" value={accountingSettings.reportFormat} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportFormat: event.target.value } : current)}>
{['CSV', 'PDF', 'BOTH'].map((option) => <option key={option} value={option}>{copy.reportFormatLabels[option] ?? option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.accountantName}</label>
<input className="input-field" value={accountingSettings.accountantName ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantName: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.accountantEmail}</label>
<input className="input-field" value={accountingSettings.accountantEmail ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantEmail: event.target.value } : current)} />
</div>
</div>
<label className="mt-4 flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={accountingSettings.autoSendReport} onChange={(event) => setAccountingSettings((current) => current ? { ...current, autoSendReport: event.target.checked } : current)} />
{copy.autoSend}
</label>
</div>
)}
</div>
)
}
@@ -0,0 +1,567 @@
'use client'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
interface Subscription {
id: string
plan: Plan
billingPeriod: BillingPeriod
status: string
currency: string
trialEndAt: string | null
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
}
interface Invoice {
id: string
amount: number
currency: string
status: string
paymentProvider: string
paidAt: string | null
createdAt: string
}
interface ProviderAvailability {
amanpay: boolean
paypal: boolean
}
interface PlanFeature {
id: string
plan: Plan
label: string
sortOrder: number
}
interface EmployeeProfile {
role?: string
}
const STATUS_BADGE: Record<string, string> = {
TRIALING: 'bg-sky-100 text-sky-700',
ACTIVE: 'bg-green-100 text-green-700',
PAST_DUE: 'bg-orange-100 text-orange-700',
CANCELLED: 'bg-slate-100 text-slate-600',
UNPAID: 'bg-red-100 text-red-700',
}
const INVOICE_STATUS: Record<string, string> = {
PAID: 'bg-green-100 text-green-700',
PENDING: 'bg-orange-100 text-orange-700',
FAILED: 'bg-red-100 text-red-700',
REFUNDED: 'bg-slate-100 text-slate-600',
}
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
export default function SubscriptionPage() {
const router = useRouter()
const { language } = useDashboardI18n()
const [subscription, setSubscription] = useState<Subscription | null>(null)
const [invoices, setInvoices] = useState<Invoice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [canViewPage, setCanViewPage] = useState<boolean | null>(null)
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
const currency = 'MAD'
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>({ amanpay: false, paypal: false })
const [planPrices, setPlanPrices] = useState<Record<string, Record<string, Record<string, number>>>>(PLAN_PRICES)
const [planFeaturesList, setPlanFeaturesList] = useState<PlanFeature[]>([])
const [paying, setPaying] = useState(false)
const [cancelling, setCancelling] = useState(false)
const copy = {
en: {
title: 'Subscription',
subtitle: 'Manage your plan, payment provider, and subscription invoices.',
trial: 'Free trial',
remaining: 'remaining. Subscribe before it ends to keep access.',
currentPlan: 'Current plan',
renews: 'renews',
cancelScheduled: 'Cancellation scheduled at end of billing period.',
undo: 'Undo',
cancelling: 'Cancelling…',
cancelPlan: 'Cancel plan',
changePlan: 'Change plan',
subscribe: 'Subscribe',
selectPlan: 'Select a plan and payment provider to proceed.',
monthly: 'Monthly',
annual: 'Annual (save ~17%)',
active: 'Active',
perMonthShort: 'mo',
perYearShort: 'yr',
paymentProvider: 'Payment provider',
total: 'Total',
perMonth: 'month',
perYear: 'year',
redirecting: 'Redirecting…',
subscribeNow: 'Subscribe now',
invoiceHistory: 'Invoice history',
date: 'Date',
provider: 'Provider',
status: 'Status',
paid: 'Paid',
amount: 'Amount',
loading: 'Loading…',
noInvoices: 'No invoices yet.',
noProviderConfigured: 'No payment provider is configured. Contact support to enable AmanPay or PayPal.',
providerUnavailable: 'This payment provider is not configured.',
statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', UNPAID: 'Unpaid' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record<string, string>,
planFeatures: {
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Marketplace listing'],
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
} as Record<Plan, string[]>,
},
fr: {
title: 'Abonnement',
subtitle: 'Gérez votre plan, le prestataire de paiement et les factures dabonnement.',
trial: 'Essai gratuit',
remaining: 'restants. Abonnez-vous avant la fin pour garder laccès.',
currentPlan: 'Plan actuel',
renews: 'renouvelle le',
cancelScheduled: 'Annulation programmée à la fin de la période.',
undo: 'Annuler',
cancelling: 'Annulation…',
cancelPlan: 'Annuler le plan',
changePlan: 'Changer de plan',
subscribe: 'Sabonner',
selectPlan: 'Sélectionnez un plan et un prestataire de paiement.',
monthly: 'Mensuel',
annual: 'Annuel (économie ~17%)',
active: 'Actif',
perMonthShort: 'mois',
perYearShort: 'an',
paymentProvider: 'Prestataire de paiement',
total: 'Total',
perMonth: 'mois',
perYear: 'an',
redirecting: 'Redirection…',
subscribeNow: 'Sabonner maintenant',
invoiceHistory: 'Historique des factures',
date: 'Date',
provider: 'Prestataire',
status: 'Statut',
paid: 'Payé',
amount: 'Montant',
loading: 'Chargement…',
noInvoices: 'Aucune facture pour le moment.',
noProviderConfigured: 'Aucun prestataire de paiement nest configuré. Contactez le support pour activer AmanPay ou PayPal.',
providerUnavailable: 'Ce prestataire de paiement nest pas configuré.',
statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', UNPAID: 'Impayé' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record<string, string>,
planFeatures: {
STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence marketplace'],
GROWTH: ['Jusqu’à 50 véhicules', '5 utilisateurs', 'Analyses complètes', 'Mise en avant prioritaire', 'Personnalisation'],
PRO: ['Véhicules illimités', 'Utilisateurs illimités', 'Rapports avancés', 'Accès API', 'Support dédié'],
} as Record<Plan, string[]>,
},
ar: {
title: 'الاشتراك',
subtitle: 'إدارة الخطة ومزوّد الدفع وفواتير الاشتراك.',
trial: 'تجربة مجانية',
remaining: 'متبقية. اشترك قبل انتهائها للحفاظ على الوصول.',
currentPlan: 'الخطة الحالية',
renews: 'يتجدد في',
cancelScheduled: 'تمت جدولة الإلغاء عند نهاية فترة الفوترة.',
undo: 'تراجع',
cancelling: 'جارٍ الإلغاء…',
cancelPlan: 'إلغاء الخطة',
changePlan: 'تغيير الخطة',
subscribe: 'اشتراك',
selectPlan: 'اختر خطة ومزوّد دفع للمتابعة.',
monthly: 'شهري',
annual: 'سنوي (توفير ~17%)',
active: 'نشط',
perMonthShort: 'شهر',
perYearShort: 'سنة',
paymentProvider: 'مزوّد الدفع',
total: 'الإجمالي',
perMonth: 'شهر',
perYear: 'سنة',
redirecting: 'جارٍ التحويل…',
subscribeNow: 'اشترك الآن',
invoiceHistory: 'سجل الفواتير',
date: 'التاريخ',
provider: 'المزوّد',
status: 'الحالة',
paid: 'مدفوع',
amount: 'المبلغ',
loading: 'جارٍ التحميل…',
noInvoices: 'لا توجد فواتير حتى الآن.',
noProviderConfigured: 'لا يوجد مزوّد دفع مهيأ. تواصل مع الدعم لتفعيل AmanPay أو PayPal.',
providerUnavailable: 'مزوّد الدفع هذا غير مهيأ.',
statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', UNPAID: 'غير مدفوع' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record<string, string>,
planFeatures: {
STARTER: ['حتى 10 مركبات', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'],
GROWTH: ['حتى 50 مركبة', '5 مستخدمين', 'تحليلات كاملة', 'إدراج ذو أولوية', 'تخصيص العلامة'],
PRO: ['مركبات غير محدودة', 'مقاعد غير محدودة', 'تقارير متقدمة', 'وصول API', 'دعم مخصص'],
} as Record<Plan, string[]>,
},
}[language]
useEffect(() => {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as EmployeeProfile
const allowed = profile.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/')
return
} catch {}
}
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me')
.then(({ employee }) => {
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
const allowed = employee.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/')
})
.catch(() => {
setCanViewPage(false)
router.replace('/')
})
}, [router])
const fetchPlanData = useCallback(async () => {
const [prices, features] = await Promise.all([
apiFetch<Record<string, Record<string, Record<string, number>>>>('/subscriptions/plans'),
apiFetch<PlanFeature[]>('/subscriptions/features'),
])
if (prices && Object.keys(prices).length > 0) setPlanPrices(prices)
if (features) setPlanFeaturesList(features)
}, [])
useEffect(() => {
if (canViewPage !== true) return
Promise.all([
apiFetch<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'),
apiFetch<ProviderAvailability>('/subscriptions/providers'),
fetchPlanData(),
])
.then(([sub, inv, availability]) => {
setProviderAvailability(availability)
if (availability.amanpay) setProvider('AMANPAY')
else if (availability.paypal) setProvider('PAYPAL')
if (sub) {
setSubscription(sub)
setSelectedPlan(sub.plan)
setBillingPeriod(sub.billingPeriod)
// currency is always MAD
}
setInvoices(inv ?? [])
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [canViewPage, fetchPlanData])
useEffect(() => {
if (canViewPage !== true) return
const handleFocus = () => { fetchPlanData().catch(() => {}) }
const handleVisibility = () => {
if (document.visibilityState === 'visible') fetchPlanData().catch(() => {})
}
window.addEventListener('focus', handleFocus)
document.addEventListener('visibilitychange', handleVisibility)
return () => {
window.removeEventListener('focus', handleFocus)
document.removeEventListener('visibilitychange', handleVisibility)
}
}, [canViewPage, fetchPlanData])
if (canViewPage !== true) {
return null
}
async function handleCheckout() {
setPaying(true)
setError(null)
try {
if (provider === 'AMANPAY' && !providerAvailability.amanpay) throw new Error(copy.providerUnavailable)
if (provider === 'PAYPAL' && !providerAvailability.paypal) throw new Error(copy.providerUnavailable)
const currentUrl = new URL(window.location.href)
currentUrl.search = ''
currentUrl.hash = ''
const result = await apiFetch<{ checkoutUrl: string }>('/subscriptions/checkout', {
method: 'POST',
body: JSON.stringify({
plan: selectedPlan,
billingPeriod,
currency,
provider,
successUrl: `${currentUrl.toString()}?payment=success`,
failureUrl: `${currentUrl.toString()}?payment=failed`,
}),
})
window.location.href = result.checkoutUrl
} catch (err: any) {
setError(err.message)
setPaying(false)
}
}
async function handleCancel() {
setCancelling(true)
setError(null)
try {
const sub = await apiFetch<Subscription>('/subscriptions/cancel', { method: 'POST' })
setSubscription(sub)
} catch (err: any) {
setError(err.message)
} finally {
setCancelling(false)
}
}
async function handleResume() {
setCancelling(true)
setError(null)
try {
const sub = await apiFetch<Subscription>('/subscriptions/resume', { method: 'POST' })
setSubscription(sub)
} catch (err: any) {
setError(err.message)
} finally {
setCancelling(false)
}
}
const planPrice = planPrices[selectedPlan]?.[billingPeriod]?.[currency]
const daysLeft = subscription?.trialEndAt
? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000)
: null
return (
<div className="space-y-8">
<div className="rdg-page-heading">
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div>
{error && (
<div className="card p-4 border-red-200 bg-red-50 text-sm text-red-700">{error}</div>
)}
{/* Trial banner */}
{subscription?.status === 'TRIALING' && daysLeft !== null && daysLeft > 0 && (
<div className="card p-4 border-sky-200 bg-sky-50 flex items-center justify-between">
<p className="text-sm font-medium text-sky-800">
{copy.trial} <strong>{daysLeft} days</strong> {copy.remaining}
</p>
</div>
)}
{/* Current plan */}
{subscription && (
<div className="card p-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.currentPlan}</p>
<div className="mt-1 flex items-center gap-3">
<h3 className="text-2xl font-bold text-slate-900">{subscription.plan}</h3>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[subscription.status] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.statusLabels[subscription.status] ?? subscription.status}
</span>
</div>
<p className="mt-1 text-sm text-slate-500">
{subscription.billingPeriod} · {subscription.currency}
{subscription.currentPeriodEnd && ` · ${copy.renews} ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}
</p>
{subscription.cancelAtPeriodEnd && (
<p className="mt-2 text-sm font-medium text-orange-700">
{copy.cancelScheduled}{' '}
<button onClick={handleResume} disabled={cancelling} className="underline">{copy.undo}</button>
</p>
)}
</div>
{!subscription.cancelAtPeriodEnd && (
<button
onClick={handleCancel}
disabled={cancelling}
className="btn-secondary text-red-600 border-red-200 hover:bg-red-50 text-sm"
>
{cancelling ? copy.cancelling : copy.cancelPlan}
</button>
)}
</div>
</div>
)}
{/* Plan selector + checkout */}
<div className="card p-6 space-y-6">
{!providerAvailability.amanpay && !providerAvailability.paypal ? (
<div className="rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-700">
{copy.noProviderConfigured}
</div>
) : null}
<div>
<h3 className="text-base font-semibold text-slate-900">
{subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe}
</h3>
<p className="mt-1 text-sm text-slate-500">{copy.selectPlan}</p>
</div>
{/* Billing period toggle */}
<div className="flex items-center gap-2">
{(['MONTHLY', 'ANNUAL'] as BillingPeriod[]).map((p) => (
<button
key={p}
onClick={() => setBillingPeriod(p)}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
billingPeriod === p ? 'bg-blue-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{p === 'MONTHLY' ? copy.monthly : copy.annual}
</button>
))}
</div>
{/* Plan cards */}
<div className="grid gap-4 md:grid-cols-3">
{PLANS.map((plan) => {
const price = planPrices[plan]?.[billingPeriod]?.[currency]
const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE'
const features = planFeaturesList.filter((f) => f.plan === plan)
return (
<button
key={plan}
onClick={() => setSelectedPlan(plan)}
className={`text-left p-5 rounded-xl border-2 transition-all ${
selectedPlan === plan
? 'border-blue-500 bg-blue-50/50'
: 'border-slate-200 hover:border-slate-300 bg-white'
} ${isActive ? 'ring-2 ring-green-200' : ''}`}
>
<div className="flex items-center justify-between">
<p className="font-semibold text-slate-900">{plan}</p>
{isActive && <span className="badge-green">{copy.active}</span>}
</div>
<p className="mt-2 text-2xl font-black text-slate-900">
{price ? formatCurrency(price, 'MAD') : '—'}
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
</p>
<ul className="mt-3 space-y-1">
{features.length > 0
? features.map((f) => (
<li key={f.id} className="text-xs text-slate-600 flex items-center gap-1.5">
<span className="text-green-500"></span> {f.label}
</li>
))
: copy.planFeatures[plan].map((f) => (
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5">
<span className="text-green-500"></span> {f}
</li>
))}
</ul>
</button>
)
})}
</div>
{/* Provider selector */}
<div>
<p className="text-sm font-medium text-slate-700 mb-2">{copy.paymentProvider}</p>
<div className="flex gap-3">
{providerAvailability.amanpay ? (
<button
onClick={() => setProvider('AMANPAY')}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
provider === 'AMANPAY' ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
🏦 AmanPay
</button>
) : null}
{providerAvailability.paypal ? (
<button
onClick={() => setProvider('PAYPAL')}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
provider === 'PAYPAL' ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
🔵 PayPal
</button>
) : null}
</div>
</div>
{/* Checkout CTA */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div>
<p className="text-sm text-slate-500">{copy.total}</p>
<p className="text-xl font-black text-slate-900">
{planPrice ? formatCurrency(planPrice, 'MAD') : '—'}
<span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear}</span>
</p>
</div>
<button
onClick={handleCheckout}
disabled={paying || loading || (!providerAvailability.amanpay && !providerAvailability.paypal)}
className="btn-primary px-8 py-3"
>
{paying ? copy.redirecting : subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow}
</button>
</div>
</div>
{/* Invoice history */}
<div className="card overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200">
<h3 className="text-base font-semibold text-slate-900">{copy.invoiceHistory}</h3>
</div>
<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.date}</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.paid}</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={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
) : invoices.length === 0 ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noInvoices}</td></tr>
) : invoices.map((inv) => (
<tr key={inv.id}>
<td className="px-6 py-4 text-sm text-slate-700">{new Date(inv.createdAt).toLocaleDateString()}</td>
<td className="px-6 py-4 text-sm text-slate-700">{inv.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 ${INVOICE_STATUS[inv.status] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.invoiceStatusLabels[inv.status] ?? inv.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-500">
{inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'}
</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">
{formatCurrency(inv.amount, 'MAD')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
+440
View File
@@ -0,0 +1,440 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import InviteModal from '@/components/team/InviteModal'
import EditMemberModal from '@/components/team/EditMemberModal'
import PermissionsMatrix from '@/components/team/PermissionsMatrix'
import { apiFetch } from '@/lib/api'
import { useTeam, TeamMember, InvitePayload } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
function initials(m: TeamMember) {
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
}
function timeAgo(dateStr: string | null, language: 'en' | 'fr' | 'ar') {
if (!dateStr) {
return language === 'fr' ? 'Jamais' : language === 'ar' ? 'أبداً' : 'Never'
}
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 2) {
return language === 'fr' ? "À l'instant" : language === 'ar' ? 'الآن' : 'Just now'
}
if (mins < 60) {
return language === 'fr' ? `il y a ${mins} min` : language === 'ar' ? `منذ ${mins} دقيقة` : `${mins}m ago`
}
const hrs = Math.floor(mins / 60)
if (hrs < 24) {
return language === 'fr' ? `il y a ${hrs}h` : language === 'ar' ? `منذ ${hrs} ساعة` : `${hrs}h ago`
}
const days = Math.floor(hrs / 24)
if (days < 30) {
return language === 'fr' ? `il y a ${days}j` : language === 'ar' ? `منذ ${days} يوم` : `${days}d ago`
}
const locale = language === 'fr' ? 'fr' : language === 'ar' ? 'ar' : 'en'
return new Date(dateStr).toLocaleDateString(locale, { month: 'short', day: 'numeric' })
}
const AVATAR_BG: string[] = [
'bg-violet-100 dark:bg-violet-900/40 text-violet-700 dark:text-violet-300',
'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300',
'bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300',
'bg-pink-100 dark:bg-pink-900/40 text-pink-700 dark:text-pink-300',
]
function avatarColor(id: string) {
const hash = id.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0)
return AVATAR_BG[hash % AVATAR_BG.length]
}
const roleLabels: Record<string, Record<'en' | 'fr' | 'ar', string>> = {
OWNER: { en: 'Owner', fr: 'Propriétaire', ar: 'مالك' },
MANAGER: { en: 'Manager', fr: 'Manager', ar: 'مدير' },
AGENT: { en: 'Agent', fr: 'Agent', ar: 'وكيل' },
}
function RoleBadge({ role, language }: { role: string; language: 'en' | 'fr' | 'ar' }) {
const styles: Record<string, string> = {
OWNER: 'bg-violet-50 dark:bg-violet-950/40 text-violet-700 dark:text-violet-300 border-violet-100 dark:border-violet-900/50',
MANAGER: 'bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-300 border-blue-100 dark:border-blue-900/50',
AGENT: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border-zinc-200 dark:border-zinc-700',
}
const label = roleLabels[role]?.[language] ?? (role.charAt(0) + role.slice(1).toLowerCase())
return (
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${styles[role] ?? styles.AGENT}`}>
{label}
</span>
)
}
function StatusBadge({ member, language }: { member: TeamMember; language: 'en' | 'fr' | 'ar' }) {
if (member.invitationStatus === 'pending') {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-orange-50 dark:bg-orange-950/30 text-orange-700 dark:text-orange-400 border-orange-100 dark:border-orange-900/50">
{language === 'fr' ? 'Invitation en attente' : language === 'ar' ? 'دعوة قيد الانتظار' : 'Pending invite'}
</span>
)
}
if (!member.isActive) {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500 border-zinc-200 dark:border-zinc-700">
{language === 'fr' ? 'Désactivé' : language === 'ar' ? 'معطل' : 'Deactivated'}
</span>
)
}
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-100 dark:border-green-900/50">
{language === 'fr' ? 'Actif' : language === 'ar' ? 'نشط' : 'Active'}
</span>
)
}
export default function TeamPage() {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Team',
subtitle: 'Manage your employees and their access levels',
inviteMember: 'Invite member',
totalMembers: 'Total members',
active: 'Active',
pendingInvite: 'Pending invite',
deactivated: 'Deactivated',
search: 'Search by name or email…',
allRoles: 'All roles',
owner: 'Owner',
manager: 'Manager',
agent: 'Agent',
allStatuses: 'All statuses',
pending: 'Pending',
loadingTeam: 'Loading team…',
noMembers: 'No members match your filters',
member: 'Member',
role: 'Role',
status: 'Status',
lastActive: 'Last active',
manage: 'Manage',
inviteSent: 'Invite sent to',
roleUpdated: 'Role updated',
memberDeactivated: 'Member deactivated',
memberReactivated: 'Member reactivated',
memberRemoved: 'Member removed',
you: '(you)',
},
fr: {
title: 'Équipe',
subtitle: "Gérez vos employés et leurs niveaux d'accès",
inviteMember: 'Inviter un membre',
totalMembers: 'Total membres',
active: 'Actif',
pendingInvite: 'Invitation en attente',
deactivated: 'Désactivé',
search: 'Rechercher par nom ou email…',
allRoles: 'Tous les rôles',
owner: 'Propriétaire',
manager: 'Manager',
agent: 'Agent',
allStatuses: 'Tous les statuts',
pending: 'En attente',
loadingTeam: "Chargement de l'équipe…",
noMembers: 'Aucun membre ne correspond aux filtres',
member: 'Membre',
role: 'Rôle',
status: 'Statut',
lastActive: 'Dernière activité',
manage: 'Gérer',
inviteSent: 'Invitation envoyée à',
roleUpdated: 'Rôle mis à jour',
memberDeactivated: 'Membre désactivé',
memberReactivated: 'Membre réactivé',
memberRemoved: 'Membre supprimé',
you: '(vous)',
},
ar: {
title: 'الفريق',
subtitle: 'إدارة الموظفين ومستويات الوصول الخاصة بهم',
inviteMember: 'دعوة عضو',
totalMembers: 'إجمالي الأعضاء',
active: 'نشط',
pendingInvite: 'دعوة قيد الانتظار',
deactivated: 'معطل',
search: 'ابحث بالاسم أو البريد الإلكتروني…',
allRoles: 'كل الأدوار',
owner: 'مالك',
manager: 'مدير',
agent: 'وكيل',
allStatuses: 'كل الحالات',
pending: 'قيد الانتظار',
loadingTeam: 'جارٍ تحميل الفريق…',
noMembers: 'لا يوجد أعضاء مطابقون للفلاتر',
member: 'العضو',
role: 'الدور',
status: 'الحالة',
lastActive: 'آخر نشاط',
manage: 'إدارة',
inviteSent: 'تم إرسال الدعوة إلى',
roleUpdated: 'تم تحديث الدور',
memberDeactivated: 'تم تعطيل العضو',
memberReactivated: 'تمت إعادة تفعيل العضو',
memberRemoved: 'تم حذف العضو',
you: '(أنت)',
},
}[language]
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
const [currentEmployeeId, setCurrentEmployeeId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<string>('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [inviteOpen, setInviteOpen] = useState(false)
const [editTarget, setEditTarget] = useState<TeamMember | null>(null)
const [toast, setToast] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
apiFetch<{ employee: { id: string } }>('/auth/employee/me')
.then(({ employee }) => {
if (!cancelled) setCurrentEmployeeId(employee.id)
})
.catch(() => {
if (!cancelled) setCurrentEmployeeId(null)
})
return () => { cancelled = true }
}, [])
const currentEmployee = members.find((member) => member.id === currentEmployeeId)
const isOwner = currentEmployee?.role === 'OWNER'
function showToast(msg: string) {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
const filtered = useMemo(() => {
return members.filter((m) => {
const q = search.toLowerCase()
const matchQ = !q ||
`${m.firstName} ${m.lastName}`.toLowerCase().includes(q) ||
m.email.toLowerCase().includes(q)
const matchRole = !roleFilter || m.role === roleFilter
const matchStatus =
!statusFilter ||
(statusFilter === 'active' && m.isActive && m.invitationStatus === 'accepted') ||
(statusFilter === 'pending' && m.invitationStatus === 'pending') ||
(statusFilter === 'inactive' && !m.isActive && m.invitationStatus === 'accepted')
return matchQ && matchRole && matchStatus
})
}, [members, search, roleFilter, statusFilter])
const handleInvite = async (payload: InvitePayload) => {
await invite(payload)
showToast(`${copy.inviteSent} ${payload.email}`)
}
const handleUpdateRole = async (id: string, role: 'MANAGER' | 'AGENT') => {
await updateRole(id, role)
showToast(copy.roleUpdated)
}
const handleDeactivate = async (id: string) => {
await deactivate(id)
showToast(copy.memberDeactivated)
}
const handleReactivate = async (id: string) => {
await reactivate(id)
showToast(copy.memberReactivated)
}
const handleRemove = async (id: string) => {
await remove(id)
showToast(copy.memberRemoved)
}
return (
<div className="space-y-6">
<div className="rdg-page-heading mb-6 flex items-start justify-between">
<div>
<h1 className="text-2xl font-medium text-zinc-900 dark:text-white">{copy.title}</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
{copy.subtitle}
</p>
</div>
{isOwner && (
<button
onClick={() => setInviteOpen(true)}
className="flex items-center gap-1.5 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 16 16">
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
{copy.inviteMember}
</button>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
{[
{ label: copy.totalMembers, value: stats.total },
{ label: copy.active, value: stats.active },
{ label: copy.pendingInvite, value: stats.pending },
{ label: copy.deactivated, value: stats.inactive },
].map((s) => (
<div
key={s.label}
className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 flex flex-col gap-1"
>
<span className="text-xs text-zinc-500 dark:text-zinc-400">{s.label}</span>
<span className="text-2xl font-medium text-zinc-900 dark:text-white">{s.value}</span>
</div>
))}
</div>
<div className="flex flex-wrap gap-2 mb-4">
<div className="relative flex-1 min-w-48">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 14 14">
<circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.2" />
<path d="M10 10L13 13" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
<input
type="text"
placeholder={copy.search}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">{copy.allRoles}</option>
<option value="OWNER">{copy.owner}</option>
<option value="MANAGER">{copy.manager}</option>
<option value="AGENT">{copy.agent}</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">{copy.allStatuses}</option>
<option value="active">{copy.active}</option>
<option value="pending">{copy.pending}</option>
<option value="inactive">{copy.deactivated}</option>
</select>
</div>
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-10">
{loading ? (
<div className="py-16 flex flex-col items-center gap-2 text-zinc-400">
<div className="w-5 h-5 border-2 border-zinc-200 dark:border-zinc-700 border-t-zinc-500 rounded-full animate-spin" />
<span className="text-sm">{copy.loadingTeam}</span>
</div>
) : error ? (
<div className="py-12 text-center text-sm text-red-500">{error}</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center">
<p className="text-sm text-zinc-500 dark:text-zinc-400">{copy.noMembers}</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.member}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.role}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.status}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">{copy.lastActive}</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{filtered.map((member) => (
<tr
key={member.id}
className={[
'border-b border-zinc-100 dark:border-zinc-800 last:border-0',
'hover:bg-zinc-50 dark:hover:bg-zinc-800/30 transition-colors',
].join(' ')}
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${avatarColor(member.id)}`}>
{initials(member)}
</div>
<div>
<div className="text-sm font-medium text-zinc-900 dark:text-white">
{member.firstName} {member.lastName}
{member.id === currentEmployeeId && (
<span className="ml-1.5 text-[10px] text-zinc-400">{copy.you}</span>
)}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</div>
</div>
</div>
</td>
<td className="px-4 py-3">
<RoleBadge role={member.role} language={language} />
</td>
<td className="px-4 py-3">
<StatusBadge member={member} language={language} />
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<span className="text-xs text-zinc-400 dark:text-zinc-500">
{member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt, language)}
</span>
</td>
<td className="px-4 py-3 text-right">
{isOwner && member.role !== 'OWNER' && (
<button
onClick={() => setEditTarget(member)}
className="text-xs px-3 py-1.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
{copy.manage}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<PermissionsMatrix />
<InviteModal
open={inviteOpen}
onClose={() => setInviteOpen(false)}
onInvite={handleInvite}
/>
<EditMemberModal
member={editTarget}
open={!!editTarget}
onClose={() => setEditTarget(null)}
onUpdateRole={handleUpdateRole}
onDeactivate={handleDeactivate}
onReactivate={handleReactivate}
onRemove={handleRemove}
/>
{toast && (
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-xl shadow-lg text-sm font-medium animate-in slide-in-from-bottom-2 duration-200">
<svg className="w-4 h-4 text-green-400 dark:text-green-600" fill="none" viewBox="0 0 16 16">
<path d="M3 8L6.5 11.5L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{toast}
</div>
)}
</div>
)
}
+3
View File
@@ -0,0 +1,3 @@
export default function PublicLayout({ children }: { children: React.ReactNode }) {
return children
}
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#0f172a" />
<text
x="16"
y="21"
text-anchor="middle"
font-family="Inter, Arial, sans-serif"
font-size="18"
font-weight="700"
fill="#ffffff"
>
R
</text>
</svg>

After

Width:  |  Height:  |  Size: 302 B

+50
View File
@@ -0,0 +1,50 @@
import type { Metadata } from 'next'
import { cookies } from 'next/headers'
import { JetBrains_Mono } from 'next/font/google'
import { DashboardI18nProvider } from '@/components/I18nProvider'
import { SHARED_LANGUAGE_COOKIE } from '@/lib/preferences'
import './globals.css'
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
})
export const metadata: Metadata = {
title: 'RentalDriveGo Dashboard',
description: 'Manage your rental car business',
icons: {
icon: '/dashboard/icon.svg',
shortcut: '/dashboard/icon.svg',
},
}
function resolveInitialLanguage(value: string | undefined): 'en' | 'fr' | 'ar' {
return value === 'fr' || value === 'ar' || value === 'en' ? value : 'en'
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies()
const initialLanguage = resolveInitialLanguage(
cookieStore.get(SHARED_LANGUAGE_COOKIE)?.value ?? cookieStore.get('dashboard-language')?.value,
)
const dir = initialLanguage === 'ar' ? 'rtl' : 'ltr'
return (
<html lang={initialLanguage} dir={dir} className="light" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html:
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('dashboard-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}var rootTheme=theme==='light'?'light':'dark';document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(rootTheme);document.documentElement.style.colorScheme=rootTheme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
}}
/>
</head>
<body className={`font-sans ${jetbrainsMono.variable}`}>
<DashboardI18nProvider initialLanguage={initialLanguage}>{children}</DashboardI18nProvider>
</body>
</html>
)
}
@@ -0,0 +1,26 @@
import * as React from "react";
import Link from 'next/link'
import { PublicPageLayout } from '@/components/public'
export default function AcceptInvitePage() {
return (
<PublicPageLayout>
<main className="flex flex-col items-center justify-center px-4 py-16">
<div className="w-full max-w-md card p-10 text-center">
<div className="mx-auto mb-6 flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
<svg className="h-7 w-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-2xl font-bold text-slate-900">Invitation accepted</h1>
<p className="mt-3 text-sm text-slate-500">
Your invitation has been processed. You can now sign in to access the team dashboard.
</p>
<Link href="/sign-in" className="mt-8 btn-primary justify-center w-full">
Sign in to dashboard
</Link>
</div>
</main>
</PublicPageLayout>
)
}
+269
View File
@@ -0,0 +1,269 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { apiFetch } from '@/lib/api'
import { PublicPageLayout } from '@/components/public'
import { marketplaceUrl } from '@/lib/urls'
type Step = 1 | 2 | 3
export default function OnboardingPage() {
const router = useRouter()
const [step, setStep] = useState<Step>(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [company, setCompany] = useState({ name: '', slug: '' })
const [brand, setBrand] = useState({
displayName: '',
tagline: '',
primaryColor: '#2563eb',
publicCity: '',
publicCountry: '',
})
const [payments, setPayments] = useState({
amanpayMerchantId: '',
amanpaySecretKey: '',
paypalEmail: '',
isListedOnMarketplace: true,
})
async function handleStep1() {
if (!company.name.trim()) return setError('Company name is required')
setLoading(true)
setError(null)
try {
await apiFetch('/companies/me', {
method: 'PATCH',
body: JSON.stringify({ name: company.name }),
})
setStep(2)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleStep2() {
if (!brand.displayName.trim()) return setError('Display name is required')
setLoading(true)
setError(null)
try {
await apiFetch('/companies/me/brand', {
method: 'PATCH',
body: JSON.stringify({
displayName: brand.displayName,
tagline: brand.tagline || undefined,
primaryColor: brand.primaryColor,
publicCity: brand.publicCity || undefined,
publicCountry: brand.publicCountry || undefined,
}),
})
setStep(3)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleStep3() {
setLoading(true)
setError(null)
try {
await apiFetch('/companies/me/brand', {
method: 'PATCH',
body: JSON.stringify({
amanpayMerchantId: payments.amanpayMerchantId || undefined,
amanpaySecretKey: payments.amanpaySecretKey || undefined,
paypalEmail: payments.paypalEmail || undefined,
isListedOnMarketplace: payments.isListedOnMarketplace,
}),
})
router.push('/')
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<PublicPageLayout>
<main className="flex items-center justify-center px-4 py-12">
<div className="w-full max-w-lg">
<div className="mb-8 text-center">
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-widest text-blue-600">RentalDriveGo</a>
<h1 className="mt-2 text-3xl font-bold text-slate-900">Set up your account</h1>
<p className="mt-1 text-sm text-slate-500">Step {step} of 3</p>
</div>
<div className="flex gap-2 mb-8">
{[1, 2, 3].map((s) => (
<div
key={s}
className={`flex-1 h-1.5 rounded-full transition-colors ${
s <= step ? 'bg-blue-600' : 'bg-slate-200'
}`}
/>
))}
</div>
<div className="card p-8">
{error && (
<div className="mb-6 p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700">
{error}
</div>
)}
{step === 1 && (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-slate-900">Your company</h2>
<p className="mt-1 text-sm text-slate-500">Tell us the basics about your rental company.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Company name</label>
<input
className="input-field"
placeholder="Casablanca Car Rentals"
value={company.name}
onChange={(e) => setCompany({ ...company, name: e.target.value })}
/>
</div>
<button onClick={handleStep1} disabled={loading} className="btn-primary w-full justify-center">
{loading ? 'Saving…' : 'Continue'}
</button>
</div>
)}
{step === 2 && (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-slate-900">Brand &amp; identity</h2>
<p className="mt-1 text-sm text-slate-500">Customize how your company appears to renters.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Display name</label>
<input
className="input-field"
placeholder="Casa Rentals"
value={brand.displayName}
onChange={(e) => setBrand({ ...brand, displayName: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Tagline <span className="text-slate-400">(optional)</span></label>
<input
className="input-field"
placeholder="The best fleet in the city"
value={brand.tagline}
onChange={(e) => setBrand({ ...brand, tagline: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">City</label>
<input
className="input-field"
placeholder="Casablanca"
value={brand.publicCity}
onChange={(e) => setBrand({ ...brand, publicCity: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Country</label>
<input
className="input-field"
placeholder="Morocco"
value={brand.publicCountry}
onChange={(e) => setBrand({ ...brand, publicCountry: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Brand color</label>
<div className="flex items-center gap-3">
<input
type="color"
className="h-10 w-14 rounded border border-slate-200 cursor-pointer"
value={brand.primaryColor}
onChange={(e) => setBrand({ ...brand, primaryColor: e.target.value })}
/>
<input
className="input-field"
value={brand.primaryColor}
onChange={(e) => setBrand({ ...brand, primaryColor: e.target.value })}
/>
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setStep(1)} className="btn-secondary flex-1 justify-center">Back</button>
<button onClick={handleStep2} disabled={loading} className="btn-primary flex-1 justify-center">
{loading ? 'Saving…' : 'Continue'}
</button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-slate-900">Payment setup</h2>
<p className="mt-1 text-sm text-slate-500">Connect payment providers so renters can book online. You can skip this for now.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">AmanPay Merchant ID <span className="text-slate-400">(optional)</span></label>
<input
className="input-field"
placeholder="your-merchant-id"
value={payments.amanpayMerchantId}
onChange={(e) => setPayments({ ...payments, amanpayMerchantId: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">AmanPay Secret Key <span className="text-slate-400">(optional)</span></label>
<input
type="password"
className="input-field"
placeholder="your-secret-key"
value={payments.amanpaySecretKey}
onChange={(e) => setPayments({ ...payments, amanpaySecretKey: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">PayPal email <span className="text-slate-400">(optional)</span></label>
<input
type="email"
className="input-field"
placeholder="payments@yourcompany.com"
value={payments.paypalEmail}
onChange={(e) => setPayments({ ...payments, paypalEmail: e.target.value })}
/>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
checked={payments.isListedOnMarketplace}
onChange={(e) => setPayments({ ...payments, isListedOnMarketplace: e.target.checked })}
/>
<span className="text-sm text-slate-700">List my company on the RentalDriveGo marketplace</span>
</label>
<div className="flex gap-3">
<button onClick={() => setStep(2)} className="btn-secondary flex-1 justify-center">Back</button>
<button onClick={handleStep3} disabled={loading} className="btn-primary flex-1 justify-center">
{loading ? 'Finishing…' : 'Go to dashboard'}
</button>
</div>
</div>
)}
</div>
</div>
</main>
</PublicPageLayout>
)
}
@@ -0,0 +1,269 @@
'use client'
import Image from 'next/image'
import { useState } from 'react'
import { apiFetch } from '@/lib/api'
import { PublicPageLayout } from '@/components/public'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
export default function SignUpForm() {
const { language, setLanguage } = useDashboardI18n()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [preferredLanguage, setPreferredLanguage] = useState<'en' | 'fr' | 'ar'>(language)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const dict = {
en: {
title: 'Create your workspace',
subtitle: 'Enter your email and password to get started.',
email: 'Email',
emailPlaceholder: 'you@company.com',
password: 'Password',
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
passwordHint: 'Minimum 8 characters',
languageLabel: 'Preferred language',
create: 'Create workspace',
creating: 'Creating\u2026',
alreadyHave: 'Already have an account?',
signIn: 'Sign in',
errorEmailTaken: 'An account with this email already exists.',
errorGeneric: 'Something went wrong. Please try again.',
successTitle: 'Check your email',
successMessage: 'We sent a verification link to {email}. Click the link to activate your account, then sign in.',
successSignIn: 'Go to sign in',
},
fr: {
title: 'Cr\u00e9ez votre espace',
subtitle: 'Saisissez votre email et mot de passe pour commencer.',
email: 'Email',
emailPlaceholder: 'vous@entreprise.com',
password: 'Mot de passe',
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
passwordHint: 'Minimum 8 caract\u00e8res',
languageLabel: 'Langue pr\u00e9f\u00e9r\u00e9e',
create: 'Cr\u00e9er mon espace',
creating: 'Cr\u00e9ation\u2026',
alreadyHave: 'Vous avez d\u00e9j\u00e0 un compte ?',
signIn: 'Se connecter',
errorEmailTaken: 'Un compte avec cet email existe d\u00e9j\u00e0.',
errorGeneric: 'Une erreur est survenue. Veuillez r\u00e9essayer.',
successTitle: 'V\u00e9rifiez votre email',
successMessage: 'Nous avons envoy\u00e9 un lien de v\u00e9rification \u00e0 {email}. Cliquez sur le lien pour activer votre compte, puis connectez-vous.',
successSignIn: 'Aller \u00e0 la connexion',
},
ar: {
title: '\u0623\u0646\u0634\u0626 \u0645\u0633\u0627\u062d\u0629 \u0639\u0645\u0644\u0643',
subtitle: '\u0623\u062f\u062e\u0644 \u0628\u0631\u064a\u062f\u0643 \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0648\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631 \u0644\u0644\u0628\u062f\u0621.',
email: '\u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a',
emailPlaceholder: 'you@company.com',
password: '\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631',
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
passwordHint: '\u0627\u0644\u062d\u062f \u0627\u0644\u0623\u062f\u0646\u0649 8 \u0623\u062d\u0631\u0641',
languageLabel: '\u0627\u0644\u0644\u063a\u0629 \u0627\u0644\u0645\u0641\u0636\u0644\u0629',
create: '\u0623\u0646\u0634\u0626 \u0627\u0644\u0645\u0633\u0627\u062d\u0629',
creating: '\u062c\u0627\u0631\u064d \u0627\u0644\u0625\u0646\u0634\u0627\u0621\u2026',
alreadyHave: '\u0647\u0644 \u0644\u062f\u064a\u0643 \u062d\u0633\u0627\u0628 \u0628\u0627\u0644\u0641\u0639\u0644\u061f',
signIn: '\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644',
errorEmailTaken: '\u064a\u0648\u062c\u062f \u062d\u0633\u0627\u0628 \u0628\u0647\u0630\u0627 \u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0628\u0627\u0644\u0641\u0639\u0644.',
errorGeneric: '\u062d\u062f\u062b \u062e\u0637\u0623 \u0645\u0627. \u064a\u0631\u062c\u0649 \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649.',
successTitle: '\u062a\u062d\u0642\u0642 \u0645\u0646 \u0628\u0631\u064a\u062f\u0643 \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a',
successMessage: '\u0623\u0631\u0633\u0644\u0646\u0627 \u0631\u0627\u0628\u0637 \u062a\u062d\u0642\u0642 \u0625\u0644\u0649 {email}. \u0627\u0646\u0642\u0631 \u0639\u0644\u0649 \u0627\u0644\u0631\u0627\u0628\u0637 \u0644\u062a\u0641\u0639\u064a\u0644 \u062d\u0633\u0627\u0628\u0643\u060c \u062b\u0645 \u0642\u0645 \u0628\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644.',
successSignIn: '\u0627\u0644\u0630\u0647\u0627\u0628 \u0625\u0644\u0649 \u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644',
},
}[preferredLanguage]
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
await apiFetch('/auth/account/start', {
method: 'POST',
body: JSON.stringify({ email, password, preferredLanguage }),
})
setLanguage(preferredLanguage)
document.cookie = `rentaldrivego-language=${preferredLanguage}; path=/; max-age=31536000; samesite=lax`
setSuccess(true)
} catch (err: any) {
if (err?.message?.includes('email_taken') || err?.code === 'email_taken') {
setError(dict.errorEmailTaken)
} else {
setError(dict.errorGeneric)
}
} finally {
setLoading(false)
}
}
if (success) {
return (
<PublicPageLayout>
<main className="flex flex-1 items-center justify-center px-4 py-16">
<div className="w-full max-w-md text-center">
<div className="flex justify-center mb-6">
<Image
src="/dashboard/rentalcardrive.png"
alt="RentalDriveGo"
width={80}
height={80}
priority
className="h-20 w-20 rounded-[1.5rem] border border-stone-200/80 bg-white/85 p-1.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/80"
/>
</div>
<div className="rounded-[2rem] border border-stone-200/80 bg-white/82 p-8 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/78">
<h1 className="text-2xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">
{dict.successTitle}
</h1>
<p className="mt-4 text-sm leading-7 text-stone-600 dark:text-stone-300">
{dict.successMessage.replace('{email}', email)}
</p>
<a
href="/sign-in"
className="mt-6 inline-flex justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{dict.successSignIn}
</a>
</div>
</div>
</main>
</PublicPageLayout>
)
}
return (
<PublicPageLayout>
<main className="flex flex-1 items-center justify-center px-4 py-16">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<div className="flex justify-center">
<a href={marketplaceUrl} target="_top">
<Image
src="/dashboard/rentalcardrive.png"
alt="RentalDriveGo"
width={80}
height={80}
priority
className="h-20 w-20 rounded-[1.5rem] border border-stone-200/80 bg-white/85 p-1.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/80"
/>
</a>
</div>
<h1 className="mt-4 text-2xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">
{dict.title}
</h1>
<p className="mt-2 text-sm text-stone-600 dark:text-stone-300">
{dict.subtitle}
</p>
</div>
<section className="rounded-[2rem] border border-stone-200/80 bg-white/82 p-8 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:border-blue-900 dark:bg-blue-950/78 dark:shadow-[0_30px_80px_rgba(0,0,0,0.26)]">
{error ? (
<div className="mb-5 rounded-[1.5rem] border border-red-200/80 bg-red-50/90 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
) : null}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.email} <span className="text-red-500">*</span>
</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={dict.emailPlaceholder}
autoFocus
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.password} <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={'\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
tabIndex={-1}
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">{dict.passwordHint}</p>
</div>
<div>
<span className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.languageLabel} <span className="text-red-500">*</span>
</span>
<div className="grid grid-cols-3 gap-2">
{(['en', 'fr', 'ar'] as const).map((lang) => (
<button
key={lang}
type="button"
onClick={() => setPreferredLanguage(lang)}
className={`rounded-2xl border py-3 text-sm font-semibold transition ${
preferredLanguage === lang
? 'border-blue-900 bg-blue-900 text-white dark:bg-orange-500 dark:border-orange-400'
: 'border-stone-200 bg-white text-stone-600 hover:border-stone-300 hover:bg-stone-50 dark:border-blue-800 dark:bg-blue-950/50 dark:text-stone-300 dark:hover:border-blue-600'
}`}
>
{lang === 'en' ? 'English' : lang === 'fr' ? 'Fran\u00e7ais' : '\u0627\u0644\u0639\u0631\u0628\u064a\u0629'}
</button>
))}
</div>
</div>
<button
type="submit"
disabled={loading}
className="inline-flex w-full justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400"
>
{loading ? dict.creating : dict.create}
</button>
</form>
<p className="mt-6 text-center text-sm text-stone-500 dark:text-stone-400">
{dict.alreadyHave}{' '}
<a
href="/sign-in"
className="font-semibold text-orange-700 hover:text-orange-800 dark:text-orange-300 dark:hover:text-orange-200"
>
{dict.signIn}
</a>
</p>
</section>
</div>
</main>
</PublicPageLayout>
)
}
@@ -0,0 +1,10 @@
import { Suspense } from 'react'
import SignUpForm from './SignUpForm'
export default function SignUpPage() {
return (
<Suspense fallback={null}>
<SignUpForm />
</Suspense>
)
}
+130
View File
@@ -0,0 +1,130 @@
'use client'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import Image from 'next/image'
import { PublicPageLayout } from '@/components/public'
import { useDashboardI18n } from '@/components/I18nProvider'
export default function VerifyEmailPage() {
const { language } = useDashboardI18n()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const dict = {
en: {
verifying: 'Verifying your email…',
success: 'Email verified! You can now sign in.',
error: 'This verification link is invalid or has expired.',
signIn: 'Go to sign in',
},
fr: {
verifying: 'Vérification de votre email…',
success: 'Email vérifié ! Vous pouvez maintenant vous connecter.',
error: 'Ce lien de vérification est invalide ou a expiré.',
signIn: 'Aller à la connexion',
},
ar: {
verifying: 'جارٍ التحقق من بريدك الإلكتروني…',
success: 'تم التحقق من البريد الإلكتروني! يمكنك الآن تسجيل الدخول.',
error: 'رابط التحقق هذا غير صالح أو منتهي الصلاحية.',
signIn: 'الذهاب إلى تسجيل الدخول',
},
}[language]
useEffect(() => {
if (!token) {
setStatus('error')
return
}
let cancelled = false
async function verify() {
try {
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1'
const res = await fetch(`${API_BASE}/auth/employee/verify-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
if (cancelled) return
if (res.ok) {
setStatus('success')
} else {
setStatus('error')
}
} catch {
if (!cancelled) setStatus('error')
}
}
verify()
return () => {
cancelled = true
}
}, [token])
return (
<PublicPageLayout>
<main className="flex flex-1 items-center justify-center px-4 py-16">
<div className="w-full max-w-md text-center">
<div className="flex justify-center mb-6">
<Image
src="/dashboard/rentalcardrive.png"
alt="RentalDriveGo"
width={80}
height={80}
priority
className="h-20 w-20 rounded-[1.5rem] border border-stone-200/80 bg-white/85 p-1.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/80"
/>
</div>
<div className="rounded-[2rem] border border-stone-200/80 bg-white/82 p-8 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/78">
{status === 'loading' && (
<>
<div className="mx-auto mb-4 h-6 w-6 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
<p className="text-sm text-stone-600 dark:text-stone-300">{dict.verifying}</p>
</>
)}
{status === 'success' && (
<>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/40">
<svg className="h-6 w-6 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-xl font-black text-blue-950 dark:text-stone-50">{dict.success}</h1>
<a
href="/sign-in"
className="mt-6 inline-flex justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{dict.signIn}
</a>
</>
)}
{status === 'error' && (
<>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40">
<svg className="h-6 w-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h1 className="text-xl font-black text-blue-950 dark:text-stone-50">{dict.error}</h1>
<a
href="/sign-in"
className="mt-6 inline-flex justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{dict.signIn}
</a>
</>
)}
</div>
</div>
</main>
</PublicPageLayout>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import {
calendarGridDays,
daysBetween,
eventsForCalendarDay,
isSameDay,
parseDateStr,
selectedBlockRange,
toDateStr,
type CalendarEvent,
} from './VehicleCalendar'
describe('VehicleCalendar helpers', () => {
it('formats and parses local date-only strings without shifting the calendar day', () => {
const parsed = parseDateStr('2026-02-09')
expect(parsed.getFullYear()).toBe(2026)
expect(parsed.getMonth()).toBe(1)
expect(parsed.getDate()).toBe(9)
expect(toDateStr(parsed)).toBe('2026-02-09')
})
it('builds a full week-aligned month grid with leading and trailing blanks', () => {
const grid = calendarGridDays(2026, 2)
expect(grid).toHaveLength(28)
expect(grid[0]?.getDate()).toBe(1)
expect(grid.at(-1)?.getDate()).toBe(28)
const march = calendarGridDays(2026, 3)
expect(march).toHaveLength(35)
expect(march[0]?.getDate()).toBe(1)
expect(march[6]?.getDate()).toBe(7)
expect(march.at(-1)).toBeNull()
})
it('treats reservation end dates as exclusive when mapping events to days', () => {
const events: CalendarEvent[] = [
{ id: 'r1', type: 'RESERVATION', startDate: '2026-06-10T10:00:00.000Z', endDate: '2026-06-13T10:00:00.000Z', status: 'CONFIRMED', label: 'Reservation' },
{ id: 'b1', type: 'BLOCK', startDate: '2026-06-13T00:00:00.000Z', endDate: '2026-06-14T00:00:00.000Z', status: null, label: 'Block' },
]
expect(eventsForCalendarDay(events, parseDateStr('2026-06-10')).map((event) => event.id)).toEqual(['r1'])
expect(eventsForCalendarDay(events, parseDateStr('2026-06-12')).map((event) => event.id)).toEqual(['r1'])
expect(eventsForCalendarDay(events, parseDateStr('2026-06-13')).map((event) => event.id)).toEqual(['b1'])
})
it('normalizes two clicked days into an inclusive visual range with exclusive end date', () => {
expect(selectedBlockRange('2026-06-12', parseDateStr('2026-06-10'))).toEqual({
startDate: '2026-06-10',
endDate: '2026-06-13',
})
expect(selectedBlockRange('2026-06-10', parseDateStr('2026-06-12'))).toEqual({
startDate: '2026-06-10',
endDate: '2026-06-13',
})
})
it('compares calendar days and calculates day spans independent of clock time', () => {
expect(isSameDay(new Date(2026, 5, 9, 1), new Date(2026, 5, 9, 23))).toBe(true)
expect(isSameDay(new Date(2026, 5, 9), new Date(2026, 5, 10))).toBe(false)
expect(daysBetween(parseDateStr('2026-06-09'), parseDateStr('2026-06-12'))).toBe(3)
})
})
@@ -0,0 +1,432 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { ChevronLeft, ChevronRight, Plus, X, Wrench, Ban, CalendarDays } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type EventType = 'RESERVATION' | 'MAINTENANCE' | 'BLOCK'
export interface CalendarEvent {
id: string
type: EventType
startDate: string
endDate: string
status: string | null
label: string
}
interface Props {
vehicleId: string
}
const EVENT_STYLES: Record<EventType, { bg: string; text: string; border: string; dot: string }> = {
RESERVATION: { bg: 'bg-blue-100', text: 'text-blue-800', border: 'border-blue-300', dot: 'bg-blue-500' },
MAINTENANCE: { bg: 'bg-orange-100', text: 'text-orange-800', border: 'border-orange-300', dot: 'bg-orange-500' },
BLOCK: { bg: 'bg-red-100', text: 'text-red-800', border: 'border-red-300', dot: 'bg-red-500' },
}
export function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10)
}
export function parseDateStr(s: string): Date {
const [y, m, day] = s.split('-').map(Number)
return new Date(y, m - 1, day)
}
export function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
export function daysBetween(start: Date, end: Date): number {
return Math.round((end.getTime() - start.getTime()) / 86400000)
}
export function calendarGridDays(year: number, month: number): (Date | null)[] {
const firstDay = new Date(year, month - 1, 1)
const daysInMonth = new Date(year, month, 0).getDate()
const startOffset = firstDay.getDay()
const gridDays: (Date | null)[] = []
for (let i = 0; i < startOffset; i++) gridDays.push(null)
for (let d = 1; d <= daysInMonth; d++) gridDays.push(new Date(year, month - 1, d))
while (gridDays.length % 7 !== 0) gridDays.push(null)
return gridDays
}
export function eventsForCalendarDay(events: CalendarEvent[], day: Date): CalendarEvent[] {
const dayStr = toDateStr(day)
return events.filter((e) => {
const start = e.startDate.slice(0, 10)
const end = e.endDate.slice(0, 10)
return start <= dayStr && dayStr < end
})
}
export function selectedBlockRange(selectStart: string, clickedDay: Date): { startDate: string; endDate: string } {
const clicked = toDateStr(clickedDay)
const start = selectStart < clicked ? selectStart : clicked
const end = selectStart < clicked ? clicked : selectStart
return {
startDate: start,
endDate: toDateStr(new Date(parseDateStr(end).getTime() + 86400000)),
}
}
export default function VehicleCalendar({ vehicleId }: Props) {
const { dict, language } = useDashboardI18n()
const c = dict.calendar
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth() + 1)
const [events, setEvents] = useState<CalendarEvent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Block-creation modal
const [showModal, setShowModal] = useState(false)
const [blockStart, setBlockStart] = useState('')
const [blockEnd, setBlockEnd] = useState('')
const [blockReason, setBlockReason] = useState('')
const [blockType, setBlockType] = useState<'MANUAL' | 'MAINTENANCE'>('MANUAL')
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
// Day-click selection for quick create
const [selectStart, setSelectStart] = useState<string | null>(null)
const fetchCalendar = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await apiFetch<CalendarEvent[]>(
`/vehicles/${vehicleId}/calendar?year=${year}&month=${month}`
)
setEvents(data)
} catch (e: any) {
setError(e.message ?? c.failedLoad)
} finally {
setLoading(false)
}
}, [vehicleId, year, month, c])
useEffect(() => { fetchCalendar() }, [fetchCalendar])
const prevMonth = () => {
if (month === 1) { setYear(y => y - 1); setMonth(12) }
else setMonth(m => m - 1)
}
const nextMonth = () => {
if (month === 12) { setYear(y => y + 1); setMonth(1) }
else setMonth(m => m + 1)
}
// Build calendar grid (6 rows × 7 cols)
const gridDays = calendarGridDays(year, month)
// Map events to date strings for fast lookup
const eventsOnDay = (day: Date): CalendarEvent[] => eventsForCalendarDay(events, day)
const isToday = (day: Date) => isSameDay(day, today)
const handleDayClick = (day: Date) => {
const str = toDateStr(day)
if (!selectStart) {
setSelectStart(str)
} else {
const range = selectedBlockRange(selectStart, day)
setBlockStart(range.startDate)
setBlockEnd(range.endDate)
setSelectStart(null)
setShowModal(true)
}
}
const openModal = () => {
setBlockStart('')
setBlockEnd('')
setBlockReason('')
setBlockType('MANUAL')
setSaveError(null)
setShowModal(true)
}
const closeModal = () => {
setShowModal(false)
setSaveError(null)
setSelectStart(null)
}
const handleSaveBlock = async () => {
if (!blockStart || !blockEnd) { setSaveError(c.datesRequired); return }
if (blockEnd <= blockStart) { setSaveError(c.endAfterStart); return }
setSaving(true)
setSaveError(null)
try {
await apiFetch(`/vehicles/${vehicleId}/calendar/blocks`, {
method: 'POST',
body: JSON.stringify({ startDate: blockStart, endDate: blockEnd, reason: blockReason || undefined, type: blockType }),
})
setShowModal(false)
fetchCalendar()
} catch (e: any) {
setSaveError(e.message ?? c.failedCreateBlock)
} finally {
setSaving(false)
}
}
const handleDeleteBlock = async (id: string) => {
if (!confirm(c.removeBlockConfirm)) return
try {
await apiFetch(`/vehicles/${vehicleId}/calendar/blocks/${id}`, { method: 'DELETE' })
fetchCalendar()
} catch (e: any) {
alert(e.message ?? c.failedDeleteBlock)
}
}
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString(localeCode, { month: 'long', year: 'numeric' })
// Separate reservations vs blocks for the legend/list below
const reservationEvents = events.filter((e) => e.type === 'RESERVATION')
const blockEvents = events.filter((e) => e.type !== 'RESERVATION')
return (
<div className="card p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CalendarDays className="w-5 h-5 text-slate-600" />
<h3 className="text-base font-semibold text-slate-900">{c.heading}</h3>
</div>
<div className="flex items-center gap-2">
<button onClick={prevMonth} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors">
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-medium text-slate-700 min-w-[130px] text-center">{monthLabel}</span>
<button onClick={nextMonth} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors">
<ChevronRight className="w-4 h-4" />
</button>
<button onClick={openModal} className="ml-2 btn-primary flex items-center gap-1.5 text-xs py-1.5 px-3">
<Plus className="w-3.5 h-3.5" /> {c.blockDates}
</button>
</div>
</div>
{selectStart && (
<div className="text-xs text-blue-600 bg-blue-50 border border-blue-200 rounded-lg px-3 py-2">
{c.clickSecondDay(selectStart!)}
<button className="ml-2 underline" onClick={() => setSelectStart(null)}>{c.cancelSelection}</button>
</div>
)}
{/* Legend */}
<div className="flex flex-wrap gap-4 text-xs text-slate-600">
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-blue-500 inline-block" />{c.legendReserved}</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-orange-500 inline-block" />{c.legendMaintenance}</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-red-500 inline-block" />{c.legendBlocked}</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block" />{c.legendAvailable}</span>
</div>
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</div>}
{/* Calendar grid */}
<div className={loading ? 'opacity-50 pointer-events-none' : ''}>
{/* Day headers */}
<div className="grid grid-cols-7 mb-1">
{c.dayHeaders.map((d) => (
<div key={d} className="text-center text-xs font-medium text-slate-400 py-1">{d}</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-px bg-slate-100 rounded-xl overflow-hidden border border-slate-100">
{gridDays.map((day, i) => {
if (!day) return <div key={`empty-${i}`} className="bg-white min-h-[72px]" />
const dayEvents = eventsOnDay(day)
const isSelected = selectStart === toDateStr(day)
const isPast = day < today && !isToday(day)
return (
<div
key={toDateStr(day)}
onClick={() => handleDayClick(day)}
className={[
'bg-white min-h-[72px] p-1.5 cursor-pointer transition-colors',
isToday(day) ? 'ring-2 ring-inset ring-blue-500' : '',
isSelected ? 'bg-blue-50' : 'hover:bg-slate-50',
isPast ? 'opacity-60' : '',
].filter(Boolean).join(' ')}
>
<div className={[
'text-xs font-medium mb-1 w-6 h-6 flex items-center justify-center rounded-full',
isToday(day) ? 'bg-blue-500 text-white' : 'text-slate-700',
].join(' ')}>
{day.getDate()}
</div>
<div className="space-y-0.5">
{dayEvents.slice(0, 2).map((ev) => {
const s = EVENT_STYLES[ev.type]
return (
<div key={ev.id} className={`text-[10px] leading-tight px-1 py-0.5 rounded truncate ${s.bg} ${s.text}`}>
{ev.label}
</div>
)
})}
{dayEvents.length > 2 && (
<div className="text-[10px] text-slate-400 pl-1">{c.moreDays(dayEvents.length - 2)}</div>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Events list for this month */}
{(reservationEvents.length > 0 || blockEvents.length > 0) && (
<div className="space-y-3 pt-2">
{reservationEvents.length > 0 && (
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">{c.reservationsThisMonth}</p>
<div className="space-y-1.5">
{reservationEvents.map((e) => {
const s = EVENT_STYLES[e.type]
const start = e.startDate.slice(0, 10)
const end = e.endDate.slice(0, 10)
const days = daysBetween(parseDateStr(start), parseDateStr(end))
return (
<div key={e.id} className={`flex items-center justify-between px-3 py-2 rounded-lg border text-sm ${s.bg} ${s.border}`}>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${s.dot}`} />
<span className={`font-medium ${s.text}`}>{e.label}</span>
<span className={`text-xs ${s.text} opacity-70`}>{start} {end} ({days}d)</span>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full bg-white/60 ${s.text}`}>{e.status}</span>
</div>
)
})}
</div>
</div>
)}
{blockEvents.length > 0 && (
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">{c.blocksAndMaintenance}</p>
<div className="space-y-1.5">
{blockEvents.map((e) => {
const s = EVENT_STYLES[e.type]
const start = e.startDate.slice(0, 10)
const end = e.endDate.slice(0, 10)
const days = daysBetween(parseDateStr(start), parseDateStr(end))
const Icon = e.type === 'MAINTENANCE' ? Wrench : Ban
return (
<div key={e.id} className={`flex items-center justify-between px-3 py-2 rounded-lg border text-sm ${s.bg} ${s.border}`}>
<div className="flex items-center gap-2">
<Icon className={`w-3.5 h-3.5 ${s.text}`} />
<span className={`font-medium ${s.text}`}>{e.label}</span>
<span className={`text-xs ${s.text} opacity-70`}>{start} {end} ({days}d)</span>
</div>
<button
onClick={() => handleDeleteBlock(e.id)}
className={`p-1 rounded hover:bg-white/60 transition-colors ${s.text}`}
title={c.removeBlock}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)
})}
</div>
</div>
)}
</div>
)}
{/* Add Block Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-blue-950/40 backdrop-blur-sm" onClick={closeModal}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 space-y-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between">
<h4 className="text-base font-semibold text-slate-900">{c.modalTitle}</h4>
<button onClick={closeModal} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100">
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3 text-sm">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.blockTypeLabel}</label>
<div className="flex gap-2">
<button
onClick={() => setBlockType('MANUAL')}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-colors ${blockType === 'MANUAL' ? 'bg-red-50 border-red-300 text-red-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}
>
<Ban className="w-3.5 h-3.5" /> {c.manualBlock}
</button>
<button
onClick={() => setBlockType('MAINTENANCE')}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-colors ${blockType === 'MAINTENANCE' ? 'bg-orange-50 border-orange-300 text-orange-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}
>
<Wrench className="w-3.5 h-3.5" /> {c.maintenanceBlock}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.startDate}</label>
<input
type="date"
className="input-field"
value={blockStart}
onChange={(e) => setBlockStart(e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.endDate}</label>
<input
type="date"
className="input-field"
value={blockEnd}
min={blockStart}
onChange={(e) => setBlockEnd(e.target.value)}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.reasonLabel} <span className="font-normal">{c.optional}</span></label>
<input
type="text"
className="input-field"
placeholder={blockType === 'MAINTENANCE' ? c.reasonPlaceholderMaintenance : c.reasonPlaceholderManual}
value={blockReason}
onChange={(e) => setBlockReason(e.target.value)}
/>
</div>
</div>
{saveError && (
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{saveError}</div>
)}
<div className="flex gap-2 pt-1">
<button onClick={closeModal} className="flex-1 btn-secondary">{c.cancel}</button>
<button onClick={handleSaveBlock} disabled={saving} className="flex-1 btn-primary">
{saving ? c.saving : c.saveBlock}
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from 'vitest'
import {
buildConfigForm,
buildRuleForm,
centsToInput,
inputToCents,
numericInputToInt,
toDateInput,
type PricingConfiguration,
type PricingRule,
} from './VehiclePricingManager'
const configuration: PricingConfiguration = {
id: 'cfg_1',
vehicleId: 'veh_1',
pricingMode: 'AUTOMATIC',
baseDailyRate: 50000,
weeklyRate: 280000,
weekendRate: 65000,
holidayRate: null,
monthlyRate: 950000,
longTermDailyRate: null,
minimumDailyRate: 40000,
maximumDailyRate: 80000,
maxDailyPriceMovementPct: 12,
automaticPricingEnabled: true,
approvalRequired: false,
createdAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-02T00:00:00.000Z',
}
const rule: PricingRule = {
id: 'rule_1',
name: 'Summer',
ruleType: 'SEASONAL',
startDate: '2026-07-01T00:00:00.000Z',
endDate: '2026-08-31T00:00:00.000Z',
dailyRate: 70000,
weeklyRate: null,
weekendRate: 90000,
monthlyRate: null,
minDailyRate: 60000,
maxDailyRate: 110000,
automaticAdjustmentPct: 15,
priceAdjustment: 5000,
isActive: false,
sortOrder: 4,
createdAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-02T00:00:00.000Z',
}
describe('VehiclePricingManager helpers', () => {
it('converts cents to display input strings and empty nullable values', () => {
expect(centsToInput(123456)).toBe('1234.56')
expect(centsToInput(0)).toBe('0.00')
expect(centsToInput(null)).toBe('')
expect(centsToInput(undefined)).toBe('')
})
it('converts currency inputs to cents while rejecting blank or invalid values', () => {
expect(inputToCents('499.99')).toBe(49999)
expect(inputToCents(' 50 ')).toBe(5000)
expect(inputToCents('')).toBeNull()
expect(inputToCents('abc')).toBeNull()
})
it('parses integer-only fields without accepting blank values', () => {
expect(numericInputToInt('12')).toBe(12)
expect(numericInputToInt('12.8')).toBe(12)
expect(numericInputToInt('')).toBeNull()
expect(numericInputToInt('nope')).toBeNull()
})
it('builds a stable editable configuration form from API pricing data', () => {
expect(buildConfigForm(configuration)).toEqual({
pricingMode: 'AUTOMATIC',
baseDailyRate: '500.00',
weeklyRate: '2800.00',
weekendRate: '650.00',
holidayRate: '',
monthlyRate: '9500.00',
longTermDailyRate: '',
minimumDailyRate: '400.00',
maximumDailyRate: '800.00',
maxDailyPriceMovementPct: '12',
automaticPricingEnabled: true,
approvalRequired: false,
})
})
it('builds an editable rule form from an existing pricing rule', () => {
expect(buildRuleForm(rule)).toEqual({
id: 'rule_1',
name: 'Summer',
ruleType: 'SEASONAL',
startDate: '2026-07-01',
endDate: '2026-08-31',
dailyRate: '700.00',
weeklyRate: '',
weekendRate: '900.00',
monthlyRate: '',
minDailyRate: '600.00',
maxDailyRate: '1100.00',
automaticAdjustmentPct: '15',
priceAdjustment: '50.00',
isActive: false,
sortOrder: '4',
})
})
it('defaults new rules to today through six days later', () => {
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
expect(buildRuleForm()).toMatchObject({
name: '',
ruleType: 'DATE_RANGE',
startDate: '2026-06-09',
endDate: '2026-06-15',
isActive: true,
sortOrder: '0',
})
vi.useRealTimers()
})
it('trims ISO timestamps for date input controls', () => {
expect(toDateInput('2026-10-04T22:15:00.000Z')).toBe('2026-10-04')
})
})
@@ -0,0 +1,848 @@
'use client'
import { useEffect, useState } from 'react'
import { CalendarRange, History, PlusCircle, Save, Sparkles, Trash2 } from 'lucide-react'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
export interface PricingConfiguration {
id: string
vehicleId: string
pricingMode: 'MANUAL' | 'AUTOMATIC'
baseDailyRate: number
weeklyRate: number | null
weekendRate: number | null
holidayRate: number | null
monthlyRate: number | null
longTermDailyRate: number | null
minimumDailyRate: number | null
maximumDailyRate: number | null
maxDailyPriceMovementPct: number
automaticPricingEnabled: boolean
approvalRequired: boolean
createdAt: string
updatedAt: string
}
export interface PricingRule {
id: string
name: string
ruleType: 'DATE_RANGE' | 'SEASONAL' | 'HOLIDAY' | 'WEEKEND' | 'SPECIAL_EVENT' | 'MANUAL_OVERRIDE'
startDate: string
endDate: string
dailyRate: number | null
weeklyRate: number | null
weekendRate: number | null
monthlyRate: number | null
minDailyRate: number | null
maxDailyRate: number | null
automaticAdjustmentPct: number | null
priceAdjustment: number | null
isActive: boolean
sortOrder: number
createdAt: string
updatedAt: string
}
export interface PricingHistoryEntry {
id: string
source: 'CONFIG_UPDATE' | 'RULE_CREATED' | 'RULE_UPDATED' | 'RULE_DELETED' | 'MANUAL_OVERRIDE'
changedByEmployeeId: string | null
previousDailyRate: number | null
nextDailyRate: number | null
previousWeeklyRate: number | null
nextWeeklyRate: number | null
note: string | null
effectiveFrom: string | null
createdAt: string
}
export interface PricingPreviewItem {
date: string
dailyRate: number
pricingType: 'MANUAL_BASE' | 'AUTOMATIC_BASE' | 'WEEKEND_RATE' | 'RULE_RATE' | 'AUTOMATIC_RULE'
matchedRuleName: string | null
}
export interface PricingResponse {
configuration: PricingConfiguration
rules: PricingRule[]
history: PricingHistoryEntry[]
preview: {
today: PricingPreviewItem
next14Days: PricingPreviewItem[]
}
}
export interface ConfigForm {
pricingMode: 'MANUAL' | 'AUTOMATIC'
baseDailyRate: string
weeklyRate: string
weekendRate: string
holidayRate: string
monthlyRate: string
longTermDailyRate: string
minimumDailyRate: string
maximumDailyRate: string
maxDailyPriceMovementPct: string
automaticPricingEnabled: boolean
approvalRequired: boolean
}
export interface RuleForm {
id?: string
name: string
ruleType: PricingRule['ruleType']
startDate: string
endDate: string
dailyRate: string
weeklyRate: string
weekendRate: string
monthlyRate: string
minDailyRate: string
maxDailyRate: string
automaticAdjustmentPct: string
priceAdjustment: string
isActive: boolean
sortOrder: string
}
export function centsToInput(value: number | null | undefined) {
return value == null ? '' : (value / 100).toFixed(2)
}
export function inputToCents(value: string) {
if (!value.trim()) return null
const parsed = Number.parseFloat(value)
if (!Number.isFinite(parsed)) return null
return Math.round(parsed * 100)
}
export function numericInputToInt(value: string) {
if (!value.trim()) return null
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) ? parsed : null
}
export function toDateInput(value: string) {
return value.slice(0, 10)
}
export function buildConfigForm(configuration: PricingConfiguration): ConfigForm {
return {
pricingMode: configuration.pricingMode,
baseDailyRate: centsToInput(configuration.baseDailyRate),
weeklyRate: centsToInput(configuration.weeklyRate),
weekendRate: centsToInput(configuration.weekendRate),
holidayRate: centsToInput(configuration.holidayRate),
monthlyRate: centsToInput(configuration.monthlyRate),
longTermDailyRate: centsToInput(configuration.longTermDailyRate),
minimumDailyRate: centsToInput(configuration.minimumDailyRate),
maximumDailyRate: centsToInput(configuration.maximumDailyRate),
maxDailyPriceMovementPct: String(configuration.maxDailyPriceMovementPct),
automaticPricingEnabled: configuration.automaticPricingEnabled,
approvalRequired: configuration.approvalRequired,
}
}
export function buildRuleForm(rule?: PricingRule): RuleForm {
const today = new Date().toISOString().slice(0, 10)
const nextWeek = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
return {
id: rule?.id,
name: rule?.name ?? '',
ruleType: rule?.ruleType ?? 'DATE_RANGE',
startDate: rule ? toDateInput(rule.startDate) : today,
endDate: rule ? toDateInput(rule.endDate) : nextWeek,
dailyRate: centsToInput(rule?.dailyRate),
weeklyRate: centsToInput(rule?.weeklyRate),
weekendRate: centsToInput(rule?.weekendRate),
monthlyRate: centsToInput(rule?.monthlyRate),
minDailyRate: centsToInput(rule?.minDailyRate),
maxDailyRate: centsToInput(rule?.maxDailyRate),
automaticAdjustmentPct: rule?.automaticAdjustmentPct != null ? String(rule.automaticAdjustmentPct) : '',
priceAdjustment: centsToInput(rule?.priceAdjustment),
isActive: rule?.isActive ?? true,
sortOrder: rule?.sortOrder != null ? String(rule.sortOrder) : '0',
}
}
export default function VehiclePricingManager({ vehicleId }: { vehicleId: string }) {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Pricing management',
subtitle: 'Manage fixed rates, automatic pricing bounds, seasonal rules, and pricing history for this vehicle.',
savePricing: 'Save pricing',
saving: 'Saving…',
loading: 'Loading pricing settings…',
mode: 'Pricing method',
manual: 'Manual fixed pricing',
automatic: 'Automatic dynamic pricing',
baseDailyRate: 'Base daily rate (MAD)',
weeklyRate: 'Weekly rate (MAD)',
weekendRate: 'Weekend rate (MAD)',
holidayRate: 'Holiday rate (MAD)',
monthlyRate: 'Monthly rate (MAD)',
longTermDailyRate: 'Long-term daily rate (MAD)',
minimumDailyRate: 'Minimum daily rate (MAD)',
maximumDailyRate: 'Maximum daily rate (MAD)',
automaticPricingEnabled: 'Automatic pricing enabled',
approvalRequired: 'Approve automatic price changes before they go live',
maxDailyMovement: 'Max daily price movement (%)',
currentPreview: 'Current pricing preview',
next14Days: 'Next 14 days',
matchedRule: 'Matched rule',
noRule: 'No rule',
rules: 'Seasonal and manual rules',
addRule: 'Add rule',
createRule: 'Create rule',
updateRule: 'Save rule',
deleting: 'Deleting…',
deleteRule: 'Delete',
noRules: 'No vehicle-specific pricing rules yet.',
history: 'Pricing history',
noHistory: 'No pricing changes recorded yet.',
active: 'Active',
inactive: 'Inactive',
startDate: 'Start date',
endDate: 'End date',
name: 'Rule name',
ruleType: 'Rule type',
adjustmentPct: 'Auto adjustment (%)',
priceAdjustment: 'Flat adjustment (MAD)',
sortOrder: 'Priority',
saveErrorBase: 'Base daily rate is required.',
saveErrorBounds: 'Minimum daily rate cannot be greater than maximum daily rate.',
saveErrorRule: 'Rules need a name plus at least one rate, bound, or adjustment.',
configSaved: 'Pricing settings saved.',
ruleSaved: 'Pricing rule saved.',
ruleCreated: 'Pricing rule created.',
ruleDeleted: 'Pricing rule deleted.',
previewLabels: {
MANUAL_BASE: 'Base manual price',
AUTOMATIC_BASE: 'Automatic base price',
WEEKEND_RATE: 'Weekend price',
RULE_RATE: 'Manual rule',
AUTOMATIC_RULE: 'Automatic rule',
} as Record<PricingPreviewItem['pricingType'], string>,
ruleTypeLabels: {
DATE_RANGE: 'Date range',
SEASONAL: 'Season',
HOLIDAY: 'Holiday',
WEEKEND: 'Weekend',
SPECIAL_EVENT: 'Special event',
MANUAL_OVERRIDE: 'Manual override',
} as Record<PricingRule['ruleType'], string>,
historyLabels: {
CONFIG_UPDATE: 'Configuration updated',
RULE_CREATED: 'Rule created',
RULE_UPDATED: 'Rule updated',
RULE_DELETED: 'Rule deleted',
MANUAL_OVERRIDE: 'Manual override',
} as Record<PricingHistoryEntry['source'], string>,
ruleHint: 'Use direct rates for fixed pricing or adjustments and bounds to guide automatic pricing.',
},
fr: {
title: 'Gestion des tarifs',
subtitle: 'Gérez les tarifs fixes, les limites automatiques, les saisons et lhistorique des prix de ce véhicule.',
savePricing: 'Enregistrer les tarifs',
saving: 'Enregistrement…',
loading: 'Chargement des paramètres tarifaires…',
mode: 'Méthode tarifaire',
manual: 'Tarification fixe manuelle',
automatic: 'Tarification dynamique automatique',
baseDailyRate: 'Tarif journalier de base (MAD)',
weeklyRate: 'Tarif hebdomadaire (MAD)',
weekendRate: 'Tarif week-end (MAD)',
holidayRate: 'Tarif jours fériés (MAD)',
monthlyRate: 'Tarif mensuel (MAD)',
longTermDailyRate: 'Tarif long séjour / jour (MAD)',
minimumDailyRate: 'Tarif journalier minimum (MAD)',
maximumDailyRate: 'Tarif journalier maximum (MAD)',
automaticPricingEnabled: 'Tarification automatique activée',
approvalRequired: 'Valider les changements automatiques avant publication',
maxDailyMovement: 'Variation maximale par jour (%)',
currentPreview: 'Aperçu des tarifs',
next14Days: '14 prochains jours',
matchedRule: 'Règle appliquée',
noRule: 'Aucune règle',
rules: 'Règles saisonnières et manuelles',
addRule: 'Ajouter une règle',
createRule: 'Créer la règle',
updateRule: 'Enregistrer la règle',
deleting: 'Suppression…',
deleteRule: 'Supprimer',
noRules: 'Aucune règle spécifique au véhicule pour le moment.',
history: 'Historique tarifaire',
noHistory: 'Aucun changement tarifaire enregistré.',
active: 'Active',
inactive: 'Inactive',
startDate: 'Date de début',
endDate: 'Date de fin',
name: 'Nom de la règle',
ruleType: 'Type de règle',
adjustmentPct: 'Ajustement auto (%)',
priceAdjustment: 'Ajustement fixe (MAD)',
sortOrder: 'Priorité',
saveErrorBase: 'Le tarif journalier de base est requis.',
saveErrorBounds: 'Le tarif minimum ne peut pas dépasser le maximum.',
saveErrorRule: 'Les règles nécessitent un nom et au moins un tarif, une limite ou un ajustement.',
configSaved: 'Paramètres tarifaires enregistrés.',
ruleSaved: 'Règle tarifaire enregistrée.',
ruleCreated: 'Règle tarifaire créée.',
ruleDeleted: 'Règle tarifaire supprimée.',
previewLabels: {
MANUAL_BASE: 'Tarif manuel de base',
AUTOMATIC_BASE: 'Base automatique',
WEEKEND_RATE: 'Tarif week-end',
RULE_RATE: 'Règle manuelle',
AUTOMATIC_RULE: 'Règle automatique',
} as Record<PricingPreviewItem['pricingType'], string>,
ruleTypeLabels: {
DATE_RANGE: 'Plage de dates',
SEASONAL: 'Saison',
HOLIDAY: 'Jour férié',
WEEKEND: 'Week-end',
SPECIAL_EVENT: 'Événement spécial',
MANUAL_OVERRIDE: 'Forçage manuel',
} as Record<PricingRule['ruleType'], string>,
historyLabels: {
CONFIG_UPDATE: 'Configuration mise à jour',
RULE_CREATED: 'Règle créée',
RULE_UPDATED: 'Règle mise à jour',
RULE_DELETED: 'Règle supprimée',
MANUAL_OVERRIDE: 'Forçage manuel',
} as Record<PricingHistoryEntry['source'], string>,
ruleHint: 'Utilisez des tarifs directs pour le fixe ou des ajustements et limites pour guider lautomatique.',
},
ar: {
title: 'إدارة التسعير',
subtitle: 'إدارة الأسعار الثابتة وحدود التسعير التلقائي والقواعد الموسمية وسجل تغييرات السعر لهذه السيارة.',
savePricing: 'حفظ التسعير',
saving: 'جارٍ الحفظ…',
loading: 'جارٍ تحميل إعدادات التسعير…',
mode: 'طريقة التسعير',
manual: 'تسعير ثابت يدوي',
automatic: 'تسعير ديناميكي تلقائي',
baseDailyRate: 'السعر اليومي الأساسي (درهم)',
weeklyRate: 'السعر الأسبوعي (درهم)',
weekendRate: 'سعر عطلة نهاية الأسبوع (درهم)',
holidayRate: 'سعر العطل (درهم)',
monthlyRate: 'السعر الشهري (درهم)',
longTermDailyRate: 'سعر الإيجار الطويل / يوم (درهم)',
minimumDailyRate: 'الحد الأدنى للسعر اليومي (درهم)',
maximumDailyRate: 'الحد الأقصى للسعر اليومي (درهم)',
automaticPricingEnabled: 'تفعيل التسعير التلقائي',
approvalRequired: 'اعتماد تغييرات السعر التلقائية قبل نشرها',
maxDailyMovement: 'أقصى تغيير يومي (%)',
currentPreview: 'معاينة التسعير الحالية',
next14Days: 'الأيام الـ 14 القادمة',
matchedRule: 'القاعدة المطبقة',
noRule: 'لا توجد قاعدة',
rules: 'القواعد الموسمية واليدوية',
addRule: 'إضافة قاعدة',
createRule: 'إنشاء القاعدة',
updateRule: 'حفظ القاعدة',
deleting: 'جارٍ الحذف…',
deleteRule: 'حذف',
noRules: 'لا توجد قواعد تسعير خاصة بهذه السيارة بعد.',
history: 'سجل التسعير',
noHistory: 'لا توجد تغييرات تسعير مسجلة بعد.',
active: 'نشطة',
inactive: 'غير نشطة',
startDate: 'تاريخ البدء',
endDate: 'تاريخ الانتهاء',
name: 'اسم القاعدة',
ruleType: 'نوع القاعدة',
adjustmentPct: 'تعديل تلقائي (%)',
priceAdjustment: 'تعديل ثابت (درهم)',
sortOrder: 'الأولوية',
saveErrorBase: 'السعر اليومي الأساسي مطلوب.',
saveErrorBounds: 'لا يمكن أن يكون الحد الأدنى أعلى من الحد الأقصى.',
saveErrorRule: 'تحتاج القواعد إلى اسم وإلى سعر أو حد أو تعديل واحد على الأقل.',
configSaved: 'تم حفظ إعدادات التسعير.',
ruleSaved: 'تم حفظ قاعدة التسعير.',
ruleCreated: 'تم إنشاء قاعدة التسعير.',
ruleDeleted: 'تم حذف قاعدة التسعير.',
previewLabels: {
MANUAL_BASE: 'السعر اليدوي الأساسي',
AUTOMATIC_BASE: 'السعر الأساسي التلقائي',
WEEKEND_RATE: 'سعر عطلة نهاية الأسبوع',
RULE_RATE: 'قاعدة يدوية',
AUTOMATIC_RULE: 'قاعدة تلقائية',
} as Record<PricingPreviewItem['pricingType'], string>,
ruleTypeLabels: {
DATE_RANGE: 'نطاق تاريخ',
SEASONAL: 'موسم',
HOLIDAY: 'عطلة',
WEEKEND: 'نهاية أسبوع',
SPECIAL_EVENT: 'مناسبة خاصة',
MANUAL_OVERRIDE: 'تجاوز يدوي',
} as Record<PricingRule['ruleType'], string>,
historyLabels: {
CONFIG_UPDATE: 'تم تحديث الإعدادات',
RULE_CREATED: 'تم إنشاء قاعدة',
RULE_UPDATED: 'تم تحديث قاعدة',
RULE_DELETED: 'تم حذف قاعدة',
MANUAL_OVERRIDE: 'تجاوز يدوي',
} as Record<PricingHistoryEntry['source'], string>,
ruleHint: 'استخدم أسعاراً مباشرة للتسعير الثابت أو تعديلات وحدوداً لتوجيه التسعير التلقائي.',
},
}[language]
const [loading, setLoading] = useState(true)
const [savingConfig, setSavingConfig] = useState(false)
const [savingRuleId, setSavingRuleId] = useState<string | null>(null)
const [deletingRuleId, setDeletingRuleId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [configForm, setConfigForm] = useState<ConfigForm | null>(null)
const [rules, setRules] = useState<RuleForm[]>([])
const [newRule, setNewRule] = useState<RuleForm>(buildRuleForm())
const [history, setHistory] = useState<PricingHistoryEntry[]>([])
const [preview, setPreview] = useState<PricingResponse['preview'] | null>(null)
function applyResponse(data: PricingResponse) {
setConfigForm(buildConfigForm(data.configuration))
setRules(data.rules.map((rule) => buildRuleForm(rule)))
setHistory(data.history)
setPreview(data.preview)
}
async function load() {
setLoading(true)
try {
const data = await apiFetch<PricingResponse>(`/vehicles/${vehicleId}/pricing`)
applyResponse(data)
setError(null)
} catch (err: any) {
setError(err.message ?? 'Failed to load vehicle pricing')
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [vehicleId])
async function saveConfiguration() {
if (!configForm) return
const baseDailyRate = inputToCents(configForm.baseDailyRate)
const minimumDailyRate = inputToCents(configForm.minimumDailyRate)
const maximumDailyRate = inputToCents(configForm.maximumDailyRate)
if (baseDailyRate == null) {
setError(copy.saveErrorBase)
return
}
if (minimumDailyRate != null && maximumDailyRate != null && minimumDailyRate > maximumDailyRate) {
setError(copy.saveErrorBounds)
return
}
setSavingConfig(true)
setError(null)
setSuccess(null)
try {
const data = await apiFetch<PricingResponse>(`/vehicles/${vehicleId}/pricing`, {
method: 'PUT',
body: JSON.stringify({
pricingMode: configForm.pricingMode,
baseDailyRate,
weeklyRate: inputToCents(configForm.weeklyRate),
weekendRate: inputToCents(configForm.weekendRate),
holidayRate: inputToCents(configForm.holidayRate),
monthlyRate: inputToCents(configForm.monthlyRate),
longTermDailyRate: inputToCents(configForm.longTermDailyRate),
minimumDailyRate,
maximumDailyRate,
maxDailyPriceMovementPct: numericInputToInt(configForm.maxDailyPriceMovementPct) ?? 10,
automaticPricingEnabled: configForm.automaticPricingEnabled,
approvalRequired: configForm.approvalRequired,
note: copy.configSaved,
}),
})
applyResponse(data)
setSuccess(copy.configSaved)
} catch (err: any) {
setError(err.message ?? copy.saveErrorBase)
} finally {
setSavingConfig(false)
}
}
async function saveRule(rule: RuleForm, create = false) {
const hasPricingEffect = [
inputToCents(rule.dailyRate),
inputToCents(rule.weeklyRate),
inputToCents(rule.weekendRate),
inputToCents(rule.monthlyRate),
inputToCents(rule.minDailyRate),
inputToCents(rule.maxDailyRate),
numericInputToInt(rule.automaticAdjustmentPct),
inputToCents(rule.priceAdjustment),
].some((value) => value != null)
if (!rule.name.trim() || !hasPricingEffect) {
setError(copy.saveErrorRule)
return
}
const payload = {
name: rule.name.trim(),
ruleType: rule.ruleType,
startDate: rule.startDate,
endDate: rule.endDate,
dailyRate: inputToCents(rule.dailyRate),
weeklyRate: inputToCents(rule.weeklyRate),
weekendRate: inputToCents(rule.weekendRate),
monthlyRate: inputToCents(rule.monthlyRate),
minDailyRate: inputToCents(rule.minDailyRate),
maxDailyRate: inputToCents(rule.maxDailyRate),
automaticAdjustmentPct: numericInputToInt(rule.automaticAdjustmentPct),
priceAdjustment: inputToCents(rule.priceAdjustment),
isActive: rule.isActive,
sortOrder: numericInputToInt(rule.sortOrder) ?? 0,
}
setSavingRuleId(rule.id ?? 'new')
setError(null)
setSuccess(null)
try {
const data = create
? await apiFetch<PricingResponse>(`/vehicles/${vehicleId}/pricing/rules`, { method: 'POST', body: JSON.stringify(payload) })
: await apiFetch<PricingResponse>(`/vehicles/${vehicleId}/pricing/rules/${rule.id}`, { method: 'PATCH', body: JSON.stringify(payload) })
applyResponse(data)
if (create) {
setNewRule(buildRuleForm())
setSuccess(copy.ruleCreated)
} else {
setSuccess(copy.ruleSaved)
}
} catch (err: any) {
setError(err.message ?? copy.saveErrorRule)
} finally {
setSavingRuleId(null)
}
}
async function deleteRule(ruleId: string) {
setDeletingRuleId(ruleId)
setError(null)
setSuccess(null)
try {
const data = await apiFetch<PricingResponse>(`/vehicles/${vehicleId}/pricing/rules/${ruleId}`, { method: 'DELETE' })
applyResponse(data)
setSuccess(copy.ruleDeleted)
} catch (err: any) {
setError(err.message ?? copy.ruleDeleted)
} finally {
setDeletingRuleId(null)
}
}
if (loading || !configForm || !preview) {
return <div className="card p-6 text-sm text-slate-500">{copy.loading}</div>
}
return (
<div className="space-y-6">
<div className="card p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">{copy.title}</h3>
<p className="mt-1 max-w-3xl text-sm text-slate-500">{copy.subtitle}</p>
</div>
<button onClick={saveConfiguration} disabled={savingConfig} className="btn-primary">
<Save className="h-4 w-4" />
{savingConfig ? copy.saving : copy.savePricing}
</button>
</div>
{error && <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
{success && <div className="mt-4 rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{success}</div>}
<div className="mt-6 grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<div className="grid gap-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 md:grid-cols-2">
<div className="md:col-span-2">
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.mode}</label>
<select className="input-field" value={configForm.pricingMode} onChange={(event) => setConfigForm((current) => current ? { ...current, pricingMode: event.target.value as ConfigForm['pricingMode'] } : current)}>
<option value="MANUAL">{copy.manual}</option>
<option value="AUTOMATIC">{copy.automatic}</option>
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.baseDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.baseDailyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, baseDailyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weeklyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.weeklyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, weeklyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weekendRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.weekendRate} onChange={(event) => setConfigForm((current) => current ? { ...current, weekendRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.holidayRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.holidayRate} onChange={(event) => setConfigForm((current) => current ? { ...current, holidayRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.monthlyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.monthlyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, monthlyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.longTermDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.longTermDailyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, longTermDailyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.minimumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.minimumDailyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, minimumDailyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.maximumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.maximumDailyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, maximumDailyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.maxDailyMovement}</label>
<input className="input-field" type="number" min="0" max="100" step="1" value={configForm.maxDailyPriceMovementPct} onChange={(event) => setConfigForm((current) => current ? { ...current, maxDailyPriceMovementPct: event.target.value } : current)} />
</div>
<div className="md:col-span-2 flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white p-4">
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
<input type="checkbox" checked={configForm.automaticPricingEnabled} onChange={(event) => setConfigForm((current) => current ? { ...current, automaticPricingEnabled: event.target.checked } : current)} />
{copy.automaticPricingEnabled}
</label>
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
<input type="checkbox" checked={configForm.approvalRequired} onChange={(event) => setConfigForm((current) => current ? { ...current, approvalRequired: event.target.checked } : current)} />
{copy.approvalRequired}
</label>
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-blue-600" />
<h4 className="text-sm font-semibold text-slate-900">{copy.currentPreview}</h4>
</div>
<div className="mt-4 rounded-2xl bg-slate-50 p-4">
<p className="text-xs uppercase tracking-wide text-slate-500">{preview.today.date}</p>
<p className="mt-2 text-2xl font-semibold text-slate-900">{formatCurrency(preview.today.dailyRate, 'MAD')}</p>
<p className="mt-1 text-sm text-slate-500">{copy.previewLabels[preview.today.pricingType]}</p>
<p className="mt-2 text-xs text-slate-400">{copy.matchedRule}: {preview.today.matchedRuleName ?? copy.noRule}</p>
</div>
<div className="mt-4">
<div className="mb-3 flex items-center gap-2">
<CalendarRange className="h-4 w-4 text-slate-400" />
<p className="text-sm font-semibold text-slate-900">{copy.next14Days}</p>
</div>
<div className="space-y-2">
{preview.next14Days.map((item) => (
<div key={item.date} className="flex items-center justify-between rounded-xl border border-slate-100 px-3 py-2 text-sm">
<div>
<p className="font-medium text-slate-900">{item.date}</p>
<p className="text-xs text-slate-500">{copy.previewLabels[item.pricingType]}</p>
</div>
<div className="text-right">
<p className="font-semibold text-slate-900">{formatCurrency(item.dailyRate, 'MAD')}</p>
<p className="text-xs text-slate-400">{item.matchedRuleName ?? copy.noRule}</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">{copy.rules}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.ruleHint}</p>
<div className="mt-5 space-y-4">
{rules.map((rule, index) => (
<div key={rule.id} className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.name}</label>
<input className="input-field" value={rule.name} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, name: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.ruleType}</label>
<select className="input-field" value={rule.ruleType} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, ruleType: event.target.value as RuleForm['ruleType'] } : entry))}>
{Object.entries(copy.ruleTypeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.startDate}</label>
<input className="input-field" type="date" value={rule.startDate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, startDate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.endDate}</label>
<input className="input-field" type="date" value={rule.endDate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, endDate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.baseDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.dailyRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, dailyRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weeklyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.weeklyRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, weeklyRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weekendRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.weekendRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, weekendRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.monthlyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.monthlyRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, monthlyRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.minimumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.minDailyRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, minDailyRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.maximumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.maxDailyRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, maxDailyRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.adjustmentPct}</label>
<input className="input-field" type="number" step="1" value={rule.automaticAdjustmentPct} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, automaticAdjustmentPct: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.priceAdjustment}</label>
<input className="input-field" type="number" step="0.01" value={rule.priceAdjustment} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, priceAdjustment: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.sortOrder}</label>
<input className="input-field" type="number" min="0" step="1" value={rule.sortOrder} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, sortOrder: event.target.value } : entry))} />
</div>
<div className="flex items-end">
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
<input type="checkbox" checked={rule.isActive} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, isActive: event.target.checked } : entry))} />
{rule.isActive ? copy.active : copy.inactive}
</label>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-end gap-2">
<button onClick={() => deleteRule(rule.id!)} disabled={deletingRuleId === rule.id} className="btn-secondary text-red-600">
<Trash2 className="h-4 w-4" />
{deletingRuleId === rule.id ? copy.deleting : copy.deleteRule}
</button>
<button onClick={() => saveRule(rule)} disabled={savingRuleId === rule.id} className="btn-primary">
<Save className="h-4 w-4" />
{savingRuleId === rule.id ? copy.saving : copy.updateRule}
</button>
</div>
</div>
))}
{rules.length === 0 && <div className="rounded-2xl border border-dashed border-slate-200 px-4 py-6 text-sm text-slate-400">{copy.noRules}</div>}
</div>
<div className="mt-6 rounded-2xl border border-blue-100 bg-blue-50/60 p-4">
<div className="mb-4 flex items-center gap-2">
<PlusCircle className="h-4 w-4 text-blue-600" />
<h4 className="text-sm font-semibold text-slate-900">{copy.addRule}</h4>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.name}</label>
<input className="input-field" value={newRule.name} onChange={(event) => setNewRule((current) => ({ ...current, name: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.ruleType}</label>
<select className="input-field" value={newRule.ruleType} onChange={(event) => setNewRule((current) => ({ ...current, ruleType: event.target.value as RuleForm['ruleType'] }))}>
{Object.entries(copy.ruleTypeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.startDate}</label>
<input className="input-field" type="date" value={newRule.startDate} onChange={(event) => setNewRule((current) => ({ ...current, startDate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.endDate}</label>
<input className="input-field" type="date" value={newRule.endDate} onChange={(event) => setNewRule((current) => ({ ...current, endDate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.baseDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.dailyRate} onChange={(event) => setNewRule((current) => ({ ...current, dailyRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weeklyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.weeklyRate} onChange={(event) => setNewRule((current) => ({ ...current, weeklyRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weekendRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.weekendRate} onChange={(event) => setNewRule((current) => ({ ...current, weekendRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.monthlyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.monthlyRate} onChange={(event) => setNewRule((current) => ({ ...current, monthlyRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.minimumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.minDailyRate} onChange={(event) => setNewRule((current) => ({ ...current, minDailyRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.maximumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.maxDailyRate} onChange={(event) => setNewRule((current) => ({ ...current, maxDailyRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.adjustmentPct}</label>
<input className="input-field" type="number" step="1" value={newRule.automaticAdjustmentPct} onChange={(event) => setNewRule((current) => ({ ...current, automaticAdjustmentPct: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.priceAdjustment}</label>
<input className="input-field" type="number" step="0.01" value={newRule.priceAdjustment} onChange={(event) => setNewRule((current) => ({ ...current, priceAdjustment: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.sortOrder}</label>
<input className="input-field" type="number" min="0" step="1" value={newRule.sortOrder} onChange={(event) => setNewRule((current) => ({ ...current, sortOrder: event.target.value }))} />
</div>
</div>
<div className="mt-4 flex justify-end">
<button onClick={() => saveRule(newRule, true)} disabled={savingRuleId === 'new'} className="btn-primary">
<PlusCircle className="h-4 w-4" />
{savingRuleId === 'new' ? copy.saving : copy.createRule}
</button>
</div>
</div>
</div>
<div className="card p-6">
<div className="flex items-center gap-2">
<History className="h-4 w-4 text-slate-400" />
<h3 className="text-base font-semibold text-slate-900">{copy.history}</h3>
</div>
<div className="mt-5 space-y-3">
{history.map((entry) => (
<div key={entry.id} className="rounded-2xl border border-slate-200 px-4 py-3 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="font-medium text-slate-900">{copy.historyLabels[entry.source]}</p>
<p className="text-slate-500">{new Date(entry.createdAt).toLocaleString()}</p>
</div>
<div className="text-right">
{entry.nextDailyRate != null && <p className="font-semibold text-slate-900">{formatCurrency(entry.nextDailyRate, 'MAD')}</p>}
{entry.previousDailyRate != null && entry.nextDailyRate != null && (
<p className="text-xs text-slate-400">{formatCurrency(entry.previousDailyRate, 'MAD')} {formatCurrency(entry.nextDailyRate, 'MAD')}</p>
)}
</div>
</div>
{entry.note && <p className="mt-2 text-sm text-slate-600">{entry.note}</p>}
</div>
))}
{history.length === 0 && <div className="rounded-2xl border border-dashed border-slate-200 px-4 py-6 text-sm text-slate-400">{copy.noHistory}</div>}
</div>
</div>
</div>
)
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { flattenInternalRoutes, isAllowedRoute, resolveAccessRedirect } from './DashboardAccessGuard'
describe('DashboardAccessGuard route helpers', () => {
it('normalizes dashboard-prefixed menu routes before checking access', () => {
const routes = flattenInternalRoutes([
{
id: 'dashboard',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/dashboard',
children: [],
},
{
id: 'fleet',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/dashboard/fleet',
children: [],
},
{
id: 'external',
itemType: 'EXTERNAL_LINK',
routeOrUrl: '/dashboard/dashboard',
children: [],
},
])
expect(routes).toEqual(['/', '/fleet'])
expect(isAllowedRoute('/', routes)).toBe(true)
expect(isAllowedRoute('/fleet/123', routes)).toBe(true)
})
it('does not redirect forever when an authenticated employee has no visible menu routes', () => {
expect(resolveAccessRedirect('/', [])).toBeNull()
expect(resolveAccessRedirect('/fleet', [])).toBeNull()
})
it('redirects disallowed routes to the first visible internal route', () => {
expect(resolveAccessRedirect('/settings', ['/', '/fleet'])).toBe('/')
expect(resolveAccessRedirect('/fleet/123', ['/', '/fleet'])).toBeNull()
})
})
@@ -0,0 +1,101 @@
'use client'
import { usePathname, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
type GeneratedMenuItem = {
id: string
itemType: 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
routeOrUrl: string | null
children: GeneratedMenuItem[]
}
type EmployeeMenuResponse = {
items: GeneratedMenuItem[]
}
export function flattenInternalRoutes(items: GeneratedMenuItem[]): string[] {
const routes: string[] = []
const walk = (entry: GeneratedMenuItem) => {
if (entry.itemType === 'INTERNAL_PAGE' && entry.routeOrUrl) {
routes.push(toDashboardAppPath(entry.routeOrUrl))
}
entry.children.forEach(walk)
}
items.forEach(walk)
return Array.from(new Set(routes))
}
export function isAllowedRoute(currentPath: string, allowedRoutes: string[]) {
return allowedRoutes.some((route) => {
if (route === '/') return currentPath === '/'
return currentPath === route || currentPath.startsWith(`${route}/`)
})
}
export function resolveAccessRedirect(currentPath: string, allowedRoutes: string[]) {
if (allowedRoutes.length === 0) return null
if (isAllowedRoute(currentPath, allowedRoutes)) return null
const fallbackRoute = allowedRoutes[0] ?? '/'
return fallbackRoute === currentPath ? null : fallbackRoute
}
export default function DashboardAccessGuard({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const [ready, setReady] = useState(false)
useEffect(() => {
let cancelled = false
async function enforceAccess() {
try {
setReady(false)
const currentPath = toDashboardAppPath(pathname)
const menu = await apiFetch<EmployeeMenuResponse>('/auth/employee/menu')
if (cancelled) return
const allowedRoutes = flattenInternalRoutes(menu.items)
const redirectPath = resolveAccessRedirect(currentPath, allowedRoutes)
if (redirectPath) {
router.replace(redirectPath)
return
}
setReady(true)
} catch (error: any) {
if (cancelled) return
if (error?.statusCode === 401 || error?.statusCode === 403 || error?.statusCode === 402) {
router.replace('/')
return
}
// Do not deadlock the dashboard on transient network failures.
setReady(true)
}
}
enforceAccess()
return () => {
cancelled = true
}
}, [pathname, router])
if (!ready) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" aria-label="Checking access" />
</div>
)
}
return <>{children}</>
}
@@ -0,0 +1,4 @@
'use client'
// Compatibility export. New pages should import PublicFooter from '@/components/public'.
export { default } from '@/components/public/PublicFooter'
@@ -0,0 +1,4 @@
'use client'
// Compatibility export. New pages should import PublicNavbar from '@/components/public'.
export { default } from '@/components/public/PublicNavbar'
@@ -0,0 +1,32 @@
import React, { isValidElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/components/public/PublicNavbar', () => ({ default: function MockPublicNavbar() { return React.createElement('mock-navbar') } }))
vi.mock('@/components/public/PublicFooter', () => ({ default: function MockPublicFooter() { return React.createElement('mock-footer') } }))
import PublicShell from './PublicShell'
function childTypes(node: React.ReactElement): string[] {
return React.Children.toArray(node.props.children).filter(isValidElement).map((child) => {
const type = child.type as any
if (typeof type === 'string') return type
return type.displayName ?? type.name ?? 'anonymous'
})
}
describe('dashboard public page compatibility shell', () => {
it('wraps public pages with navbar, content, and footer by default', () => {
const shell = PublicShell({ children: React.createElement('main', { id: 'content' }) })
expect(isValidElement(shell)).toBe(true)
expect(childTypes(shell)).toEqual(['MockPublicNavbar', 'div', 'MockPublicFooter'])
})
it('omits chrome when embedded so partner surfaces do not get nested navigation', () => {
const shell = PublicShell({ embedded: true, children: React.createElement('main', { id: 'content' }) })
expect(childTypes(shell)).toEqual(['div'])
const contentWrapper = React.Children.toArray(shell.props.children).find(isValidElement) as React.ReactElement
expect(contentWrapper.props.children).toEqual(React.createElement('main', { id: 'content' }))
})
})
@@ -0,0 +1,5 @@
'use client'
// Compatibility export. New pages should import PublicPageLayout from '@/components/public'.
export { default } from '@/components/public/PublicPageLayout'
export type { PublicPageLayoutProps } from '@/components/public/PublicPageLayout'
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { hasRenderableMenuItems } from './Sidebar'
describe('Sidebar menu rendering helpers', () => {
it('treats an empty employee menu as non-renderable so fallback navigation remains available', () => {
expect(hasRenderableMenuItems(null)).toBe(false)
expect(hasRenderableMenuItems([])).toBe(false)
})
it('ignores structural-only menu responses without clickable entries', () => {
expect(hasRenderableMenuItems([
{
id: 'section',
systemKey: null,
label: 'Workspace',
itemType: 'SECTION_LABEL',
routeOrUrl: null,
icon: null,
parentId: null,
openInNewTab: false,
displayOrder: 1,
children: [],
},
{
id: 'divider',
systemKey: null,
label: '',
itemType: 'DIVIDER',
routeOrUrl: null,
icon: null,
parentId: null,
openInNewTab: false,
displayOrder: 2,
children: [],
},
])).toBe(false)
})
it('detects nested clickable menu entries', () => {
expect(hasRenderableMenuItems([
{
id: 'parent',
systemKey: null,
label: 'Operations',
itemType: 'PARENT_MENU',
routeOrUrl: null,
icon: null,
parentId: null,
openInNewTab: false,
displayOrder: 1,
children: [
{
id: 'fleet',
systemKey: 'fleet',
label: 'Fleet',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/fleet',
icon: 'Car',
parentId: 'parent',
openInNewTab: false,
displayOrder: 1,
children: [],
},
],
},
])).toBe(true)
})
})
+482
View File
@@ -0,0 +1,482 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import type { ReactNode } from 'react'
import { useEffect, useLayoutEffect, useState } from 'react'
import {
LayoutDashboard,
Car,
Calendar,
Globe,
Users,
Tag,
UserPlus,
BarChart2,
CreditCard,
Bell,
Settings,
LogOut,
FileText,
ChevronLeft,
ChevronRight,
Star,
AlertTriangle,
} from 'lucide-react'
import { DashboardLanguageSwitcher, DashboardThemeSwitcher, useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
import { SHARED_LANGUAGE_KEY, readCurrentUserScopedPreference } from '@/lib/preferences'
interface BrandSettings {
displayName: string
logoUrl: string | null
}
interface CompanySummary {
name: string
brand?: {
displayName?: string | null
logoUrl?: string | null
} | null
}
interface SidebarUser {
displayName: string
subtitle: string
initials: string
}
interface EmployeeProfile {
email: string
firstName: string
lastName: string
role: string
preferredLanguage?: string
}
interface GeneratedMenuItem {
id: string
systemKey: string | null
label: string
itemType: 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
routeOrUrl: string | null
icon: string | null
parentId: string | null
openInNewTab: boolean
displayOrder: number
children: GeneratedMenuItem[]
}
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return 'U'
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
}
function toSidebarUser(profile: Partial<EmployeeProfile>, fallbackName: string, fallbackSubtitle: string): SidebarUser {
const fullName = `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()
const email = profile.email ?? ''
const role = profile.role ?? ''
const displayName = fullName || (email ? email.split('@')[0] : fallbackName)
const subtitle = email || role || fallbackSubtitle
const initials = computeInitials(fullName || email || fallbackName)
return { displayName, subtitle, initials }
}
const NAV_ITEMS = [
{ href: '/', key: 'dashboard', icon: LayoutDashboard, exact: true, minRole: 'AGENT' },
{ href: '/fleet', key: 'fleet', icon: Car, minRole: 'AGENT' },
{ href: '/reservations', key: 'reservations', icon: Calendar, minRole: 'AGENT' },
{ href: '/online-reservations', key: 'onlineReservations', icon: Globe, minRole: 'AGENT' },
{ href: '/customers', key: 'customers', icon: Users, minRole: 'AGENT' },
{ href: '/offers', key: 'offers', icon: Tag, minRole: 'MANAGER' },
{ href: '/team', key: 'team', icon: UserPlus, minRole: 'AGENT' },
{ href: '/reports', key: 'reports', icon: BarChart2, minRole: 'MANAGER' },
{ href: '/subscription', key: 'subscription', icon: CreditCard, minRole: 'OWNER' },
{ href: '/billing', key: 'billing', icon: CreditCard, minRole: 'MANAGER' },
{ href: '/contracts', key: 'contracts', icon: FileText, minRole: 'AGENT' },
{ href: '/reviews', key: 'reviews', icon: Star, minRole: 'AGENT' },
{ href: '/complaints', key: 'complaints', icon: AlertTriangle, minRole: 'AGENT' },
{ href: '/notifications', key: 'notifications', icon: Bell, minRole: 'AGENT' },
{ href: '/settings', key: 'settings', icon: Settings, minRole: 'OWNER' },
] as const
const ICON_MAP = {
LayoutDashboard,
Car,
Calendar,
Globe,
Users,
Tag,
UserPlus,
BarChart2,
CreditCard,
Bell,
Settings,
FileText,
Star,
AlertTriangle,
} as const
const SIDEBAR_BRAND_KEY = 'dashboard_brand'
const ROLE_RANK: Record<string, number> = { OWNER: 3, MANAGER: 2, AGENT: 1 }
function hasMinRole(employeeRole: string, minRole: string): boolean {
return (ROLE_RANK[employeeRole] ?? 0) >= (ROLE_RANK[minRole] ?? 0)
}
export function hasRenderableMenuItems(items: GeneratedMenuItem[] | null): boolean {
if (!items?.length) return false
return items.some((item) => {
if (item.itemType === 'DIVIDER' || item.itemType === 'SECTION_LABEL') return false
if (item.itemType === 'PARENT_MENU') return hasRenderableMenuItems(item.children)
return Boolean(item.routeOrUrl)
})
}
function notifyParent(message: Record<string, unknown>) {
if (typeof window === 'undefined' || window.parent === window) return
window.parent.postMessage(message, '*')
}
export default function Sidebar() {
const { dict, language, setLanguage } = useDashboardI18n()
const isRtl = language === 'ar'
const pathname = usePathname()
const appPath = toDashboardAppPath(pathname)
const [brand, setBrand] = useState<BrandSettings | null>(null)
const [user, setUser] = useState<SidebarUser>({
displayName: dict.workspaceUser,
subtitle: dict.localAuth,
initials: 'W',
})
const [role, setRole] = useState<string>('AGENT')
const [menuItems, setMenuItems] = useState<GeneratedMenuItem[] | null>(null)
const [open, setOpen] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
useLayoutEffect(() => {
try {
const cached = window.localStorage.getItem(SIDEBAR_BRAND_KEY)
if (!cached) return
const parsed = JSON.parse(cached) as Partial<BrandSettings>
if (typeof parsed.displayName === 'string' && parsed.displayName.trim()) {
setBrand({
displayName: parsed.displayName,
logoUrl: typeof parsed.logoUrl === 'string' ? parsed.logoUrl : null,
})
}
} catch {}
}, [])
useEffect(() => {
let cancelled = false
async function loadBrand() {
try {
const brandData = await apiFetch<BrandSettings | null>('/companies/me/brand')
if (cancelled) return
if (brandData?.displayName?.trim()) {
setBrand(brandData)
window.localStorage.setItem(SIDEBAR_BRAND_KEY, JSON.stringify(brandData))
return
}
} catch {}
try {
const company = await apiFetch<CompanySummary>('/companies/me')
if (cancelled) return
const resolvedBrand: BrandSettings = {
displayName: company.brand?.displayName?.trim() || company.name,
logoUrl: company.brand?.logoUrl ?? null,
}
setBrand(resolvedBrand)
window.localStorage.setItem(SIDEBAR_BRAND_KEY, JSON.stringify(resolvedBrand))
} catch {}
}
loadBrand()
return () => { cancelled = true }
}, [])
useEffect(() => {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as Partial<EmployeeProfile>
setUser(toSidebarUser(profile, dict.workspaceUser, dict.localAuth))
if (profile.role) setRole(profile.role)
} catch {}
}
let cancelled = false
Promise.all([
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me'),
apiFetch<{ items: GeneratedMenuItem[] }>('/auth/employee/menu').catch(() => null),
])
.then(([{ employee }, menu]) => {
if (cancelled) return
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth))
if (employee.role) setRole(employee.role)
if (menu?.items) setMenuItems(menu.items)
if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') {
// Only apply the server-side preference when the employee has no local preference
// stored yet — avoids overriding a language the user manually switched to.
const existingPref = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
if (!existingPref) {
setLanguage(employee.preferredLanguage)
document.cookie = `rentaldrivego-language=${employee.preferredLanguage}; path=/; max-age=31536000; samesite=lax`
}
}
})
.catch(() => {
if (cancelled) return
if (!cached) setUser(toSidebarUser({}, dict.workspaceUser, dict.localAuth))
})
return () => { cancelled = true }
}, [dict.localAuth, dict.workspaceUser])
// Close sidebar when navigating on mobile
useEffect(() => { setOpen(false) }, [pathname])
const isActive = (item: typeof NAV_ITEMS[number]) => {
if ('exact' in item && item.exact) return appPath === item.href
return appPath.startsWith(item.href)
}
const isGeneratedItemActive = (item: GeneratedMenuItem) => {
if (!item.routeOrUrl || item.itemType !== 'INTERNAL_PAGE') return false
const route = toDashboardAppPath(item.routeOrUrl)
if (route === '/') return appPath === '/'
return appPath === route || appPath.startsWith(`${route}/`)
}
const fallbackMenuItems = NAV_ITEMS
.filter((item) => !mounted || hasMinRole(role, item.minRole))
.map((item) => ({
id: item.href,
systemKey: item.key,
label: dict.nav[item.key] ?? item.key,
itemType: 'INTERNAL_PAGE' as const,
routeOrUrl: item.href,
icon: item.icon.name,
parentId: null,
openInNewTab: false,
displayOrder: 0,
children: [],
}))
const useGeneratedMenu = hasRenderableMenuItems(menuItems)
const resolvedMenuItems = useGeneratedMenu && menuItems ? menuItems : fallbackMenuItems
function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode {
return items.map((item) => {
const label = item.systemKey ? (dict.nav[item.systemKey] ?? item.label) : item.label
const paddingClass = depth > 0 ? 'pl-6' : ''
const Icon = item.icon && item.icon in ICON_MAP ? ICON_MAP[item.icon as keyof typeof ICON_MAP] : null
if (item.itemType === 'DIVIDER') {
return <div key={item.id} className="mx-3 my-3 h-px bg-gradient-to-r from-transparent via-blue-400/20 to-transparent" />
}
if (item.itemType === 'SECTION_LABEL') {
return (
<p key={item.id} className="px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
{label}
</p>
)
}
if (item.itemType === 'PARENT_MENU') {
return (
<div key={item.id} className="space-y-1">
<div className={`rdg-nav-item flex items-center gap-3 rounded-lg px-3.5 py-2.5 text-sm font-medium text-slate-600 dark:text-slate-300 ${paddingClass}`}>
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
{label}
</div>
<div className="space-y-0.5">{renderGeneratedMenu(item.children, depth + 1)}</div>
</div>
)
}
const active = mounted && isGeneratedItemActive(item)
const className = [
'rdg-nav-item relative flex items-center gap-3 rounded-lg px-3.5 py-3 text-sm font-medium transition-all duration-200',
paddingClass,
active
? 'bg-blue-500/10 text-blue-950 shadow-[inset_2px_0_0_#3b82f6,0_0_18px_rgba(59,130,246,0.10)] dark:text-slate-50'
: 'text-slate-500 hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-slate-50',
].join(' ')
if (item.itemType === 'EXTERNAL_LINK' && item.routeOrUrl) {
return (
<a
key={item.id}
href={item.routeOrUrl}
target={item.openInNewTab ? '_blank' : '_self'}
rel={item.openInNewTab ? 'noreferrer noopener' : undefined}
className={className}
data-active={active ? 'true' : 'false'}
>
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
{label}
</a>
)
}
const href = item.itemType === 'INTERNAL_PAGE' ? toDashboardAppPath(item.routeOrUrl) : (item.routeOrUrl || '/')
return (
<Link key={item.id} href={href} className={className} aria-current={active ? 'page' : undefined} data-active={active ? 'true' : 'false'}>
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
{label}
</Link>
)
})
}
function signOut() {
localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
void fetch('/dashboard/api/v1/auth/employee/logout', { method: 'POST', credentials: 'include' })
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
notifyParent({ type: 'rentaldrivego:employee-logout' })
window.location.href = marketplaceUrl
}
return (
<>
{/* Mobile backdrop */}
{open && (
<div
className="fixed inset-0 z-30 bg-blue-950/50 lg:hidden"
onClick={() => setOpen(false)}
/>
)}
{/* Tab to open sidebar — only visible when sidebar is closed on mobile */}
{!open && (
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Open sidebar"
className={[
'fixed top-1/2 z-50 flex h-14 w-6 -translate-y-1/2 items-center justify-center bg-[#0a1128] text-white shadow-lg lg:hidden',
isRtl ? 'right-0 rounded-l-lg' : 'left-0 rounded-r-lg',
].join(' ')}
>
{isRtl ? <ChevronLeft className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
)}
<aside
className={[
'rdg-sidebar fixed inset-y-0 z-40 flex w-[var(--rdg-sidebar-size)] flex-col border-e border-blue-200/70 bg-white/90 text-blue-950 shadow-[8px_0_32px_rgba(15,23,42,0.08)] backdrop-blur-xl transition-[transform,background-color,border-color,color,box-shadow] duration-300 dark:border-blue-400/10 dark:bg-[#0a0f1a]/95 dark:text-slate-100 dark:shadow-[8px_0_32px_rgba(0,0,0,0.22)]',
isRtl ? 'right-0' : 'left-0',
'lg:translate-x-0',
open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'),
].join(' ')}
>
<a
href={marketplaceUrl}
target="_top"
className="rdg-sidebar-brand flex items-center gap-3 border-b border-blue-200/70 px-5 py-4 transition-colors dark:border-blue-400/10"
>
<div className="rdg-brand-mark flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden">
{brand?.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={brand.logoUrl} alt={brand.displayName} className="h-8 w-8 object-cover" />
) : (
<Car className="h-4 w-4 text-white" />
)}
</div>
<span className="truncate bg-gradient-to-br from-blue-950 to-blue-600 bg-clip-text text-sm font-semibold tracking-[-0.01em] text-transparent dark:from-slate-100 dark:to-blue-300">
{brand?.displayName ?? 'RentalDriveGo'}
</span>
</a>
<nav className="rdg-sidebar-nav flex-1 space-y-1.5 overflow-y-auto px-3 py-5">
{useGeneratedMenu ? renderGeneratedMenu(resolvedMenuItems) : NAV_ITEMS.filter((item) => !mounted || hasMinRole(role, item.minRole)).map((item) => {
const Icon = item.icon
const active = mounted && isActive(item)
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? 'page' : undefined}
data-active={active ? 'true' : 'false'}
className={[
'rdg-nav-item relative flex items-center gap-3 rounded-lg px-3.5 py-3 text-sm font-medium transition-all duration-200',
active
? 'bg-blue-500/10 text-blue-950 shadow-[inset_2px_0_0_#3b82f6,0_0_18px_rgba(59,130,246,0.10)] dark:text-slate-50'
: 'text-slate-500 hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-slate-50',
].join(' ')}
>
<Icon className={['h-[18px] w-[18px] flex-shrink-0', active ? 'text-blue-700 dark:text-blue-300' : ''].join(' ')} />
{dict.nav[item.key]}
{item.key === 'dashboard' ? (
<span className="ml-auto rounded-full border border-blue-400/30 bg-blue-500/15 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:text-blue-300">
Live
</span>
) : null}
{item.key === 'notifications' && active ? (
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-orange-400 shadow-[0_0_10px_rgba(249,115,22,0.5)]" />
) : null}
</Link>
)
})}
</nav>
<div className="rdg-sidebar-footer border-t border-blue-200/70 px-3 py-4 dark:border-blue-400/10">
<div className="rdg-user-card rounded-xl border border-blue-200/70 bg-blue-50/75 p-3 backdrop-blur dark:border-blue-400/10 dark:bg-blue-500/[0.06]">
<div className="flex items-center gap-3">
<div className="rdg-avatar flex h-9 w-9 flex-shrink-0 items-center justify-center text-xs font-semibold">
{user.initials}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-blue-950 dark:text-white">{user.displayName}</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">{user.subtitle}</p>
</div>
</div>
</div>
<button
type="button"
onClick={signOut}
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-slate-500 transition-colors hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-white"
>
<LogOut className="h-4 w-4" />
{dict.signOut}
</button>
<div className="mt-3 flex items-center gap-2 border-t border-blue-200/70 pt-3 dark:border-blue-400/10">
<DashboardLanguageSwitcher />
<DashboardThemeSwitcher />
</div>
</div>
{/* Arrow tab to collapse sidebar (mobile only, sticks to outer edge) */}
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Close sidebar"
className={[
'absolute top-1/2 z-10 flex h-14 w-5 -translate-y-1/2 items-center justify-center bg-[#0a1128] text-white shadow-lg lg:hidden',
isRtl ? '-left-5 rounded-l-lg' : '-right-5 rounded-r-lg',
].join(' ')}
>
{isRtl ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronLeft className="h-3.5 w-3.5" />}
</button>
</aside>
</>
)
}
+289
View File
@@ -0,0 +1,289 @@
'use client'
import Link from 'next/link'
import { Bell, Search, Settings } from 'lucide-react'
import { usePathname, useRouter } from 'next/navigation'
import { useState, useEffect } from 'react'
import { io } from 'socket.io-client'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return 'U'
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
}
function resolveSocketOrigin(): string | null {
if (typeof window === 'undefined') return null
const configuredApiUrl = process.env.NEXT_PUBLIC_API_URL
if (configuredApiUrl) {
try {
return new URL(configuredApiUrl, window.location.origin).origin
} catch {}
}
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
return 'http://localhost:4000'
}
return window.location.origin
}
export default function TopBar() {
const { dict } = useDashboardI18n()
const pathname = usePathname()
const appPath = toDashboardAppPath(pathname)
const router = useRouter()
const [unreadCount, setUnreadCount] = useState(0)
const [showNotifs, setShowNotifs] = useState(false)
const [userInitials, setUserInitials] = useState('W')
const [notifications, setNotifications] = useState<Array<{
id: string
title: string
body: string
readAt: string | null
createdAt: string
}>>([])
const [loadingNotifs, setLoadingNotifs] = useState(false)
const [socketEnabled, setSocketEnabled] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
const title = (() => {
if (!mounted) return dict.titles['/']
if (dict.titles[appPath]) return dict.titles[appPath]
if (appPath.startsWith('/contracts/')) return dict.titles['/contracts'] ?? dict.titles['/']
if (appPath.startsWith('/reservations/')) return dict.titles['/reservations'] ?? dict.titles['/']
return dict.titles['/']
})()
async function refreshUnreadCount() {
try {
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
setUnreadCount(data.unread)
setSocketEnabled(true)
} catch {
setUnreadCount(0)
setSocketEnabled(false)
}
}
useEffect(() => {
refreshUnreadCount()
}, [pathname])
useEffect(() => {
function onNotificationsUpdated() {
refreshUnreadCount()
}
window.addEventListener('notifications:updated', onNotificationsUpdated)
return () => window.removeEventListener('notifications:updated', onNotificationsUpdated)
}, [])
useEffect(() => {
if (!socketEnabled) return
const socketOrigin = resolveSocketOrigin()
if (!socketOrigin) return
const socket = io(socketOrigin, {
autoConnect: false,
withCredentials: true,
transports: ['polling', 'websocket'],
})
const handleNotification = () => {
setUnreadCount((prev) => prev + 1)
window.dispatchEvent(new Event('notifications:updated'))
}
socket.on('notification', handleNotification)
const connectTimer = window.setTimeout(() => {
socket.connect()
}, 0)
return () => {
window.clearTimeout(connectTimer)
socket.off('notification', handleNotification)
socket.disconnect()
}
}, [socketEnabled])
useEffect(() => {
if (!showNotifs) return
let cancelled = false
setLoadingNotifs(true)
apiFetch<Array<{
id: string
title: string
body: string
readAt: string | null
createdAt: string
}>>('/notifications/company')
.then((data) => {
if (cancelled) return
setNotifications(data.slice(0, 8))
})
.catch(() => {
if (cancelled) return
setNotifications([])
})
.finally(() => {
if (cancelled) return
setLoadingNotifs(false)
})
return () => { cancelled = true }
}, [showNotifs])
useEffect(() => {
setShowNotifs(false)
}, [pathname])
useEffect(() => {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as { email?: string; firstName?: string; lastName?: string }
const fullName = `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()
const email = profile.email ?? ''
setUserInitials(computeInitials(fullName || email || dict.workspaceUser))
} catch {}
}
let cancelled = false
apiFetch<{
employee: {
email: string
firstName: string
lastName: string
}
}>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim()
const email = employee.email ?? ''
setUserInitials(computeInitials(fullName || email || dict.workspaceUser))
})
.catch(() => {
if (cancelled) return
setUserInitials(computeInitials(dict.workspaceUser))
})
return () => { cancelled = true }
}, [dict.workspaceUser])
async function openNotificationsInbox(notificationId?: string) {
if (notificationId) {
try {
await apiFetch(`/notifications/company/${notificationId}/read`, { method: 'POST' })
setUnreadCount((current) => Math.max(current - 1, 0))
window.dispatchEvent(new Event('notifications:updated'))
} catch {}
}
setShowNotifs(false)
router.push('/notifications')
}
return (
<header className="rdg-topbar relative z-[70] px-4 sm:px-6">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0 shrink-0">
<p className="rdg-topbar-kicker hidden sm:block">RentalDriveGo</p>
<h1 className="rdg-topbar-title truncate">{title}</h1>
</div>
<div className="rdg-topbar-search relative hidden w-[min(380px,34vw)] md:block">
<Search className="pointer-events-none absolute start-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input
type="search"
placeholder="Search vehicles, bookings, customers..."
className="rdg-search-field h-10 w-full ps-10 pe-4 text-sm outline-none"
/>
</div>
<div className="rdg-system-status hidden xl:inline-flex">
System operational
</div>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<button
onClick={() => setShowNotifs(!showNotifs)}
className="relative inline-flex h-11 w-11 items-center justify-center rounded-[var(--rdg-radius-sm)] text-[var(--rdg-text-secondary)] transition-colors hover:bg-[var(--rdg-soft-blue)] hover:text-[var(--rdg-action-primary)]"
>
<Bell className="h-5 w-5" />
{unreadCount > 0 ? (
<span className="absolute right-1 top-1 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-orange-500 px-1 text-[10px] font-bold text-white ring-2 ring-white dark:ring-[#0a0f1a]">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}
</button>
{showNotifs ? (
<div className="card absolute top-full z-[80] mt-2 w-80 p-4 [inset-inline-end:0]">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-sm font-medium text-blue-950 dark:text-slate-100">{dict.notifications}</p>
<button
type="button"
onClick={() => openNotificationsInbox()}
className="text-xs font-medium text-orange-700 hover:text-blue-950 dark:text-orange-300 dark:hover:text-white"
>
View all
</button>
</div>
{loadingNotifs ? (
<p className="text-sm text-slate-500 dark:text-slate-400">Loading</p>
) : notifications.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
</p>
) : (
<div className="space-y-1">
{notifications.map((notification) => (
<button
key={notification.id}
type="button"
onClick={() => openNotificationsInbox(notification.id)}
className={[
'w-full rounded-lg px-3 py-2 text-left transition-colors hover:bg-blue-500/10',
notification.readAt ? 'opacity-80' : '',
].join(' ')}
>
<p className="truncate text-sm font-medium text-blue-950 dark:text-slate-100">
{notification.title}
</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
{notification.body}
</p>
</button>
))}
</div>
)}
</div>
) : null}
</div>
<Link
href="/settings"
className="hidden h-11 w-11 items-center justify-center rounded-[var(--rdg-radius-sm)] text-[var(--rdg-text-secondary)] transition-colors hover:bg-[var(--rdg-soft-blue)] hover:text-[var(--rdg-action-primary)] sm:inline-flex"
aria-label="Settings"
>
<Settings className="h-5 w-5" />
</Link>
<div className="rdg-avatar flex h-9 w-9 items-center justify-center text-xs font-semibold">
{userInitials}
</div>
</div>
</header>
)
}
@@ -0,0 +1,187 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import {
type DashboardLanguage,
useDashboardI18n,
} from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
type FooterItem = {
label: string
href?: string
}
const appPrivacyHref = {
en: `${marketplaceUrl}/app-privacy-en`,
fr: `${marketplaceUrl}/app-privacy-fr`,
ar: `${marketplaceUrl}/app-privacy-ar`,
} as const
const localeOptions: Array<{ value: DashboardLanguage; label: string; flag: string }> = [
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' },
{ value: 'ar', label: 'North Africa (Arabic)', flag: '🇲🇦' },
{ value: 'fr', label: 'Europe (French)', flag: '🇫🇷' },
]
function getFooterContent(language: DashboardLanguage): {
primary: FooterItem[]
secondary: FooterItem[]
localeLabel: string
rightsLabel: string
} {
switch (language) {
case 'fr':
return {
primary: [
{ label: 'À propos de nous', href: `${marketplaceUrl}/footer/about-us` },
{ label: 'Conditions dutilisation', href: `${marketplaceUrl}/footer/terms-of-service` },
{ label: 'Sécurité', href: `${marketplaceUrl}/footer/security` },
{ label: 'Conformité', href: `${marketplaceUrl}/footer/compliance` },
{ label: 'Politique de confidentialité', href: appPrivacyHref.fr },
{ label: 'Politique relative aux cookies', href: `${marketplaceUrl}/footer/cookie-policy` },
],
secondary: [
{ label: 'Newsletter', href: `${marketplaceUrl}/footer/newsletter` },
{ label: 'Contacter les ventes', href: `${marketplaceUrl}/footer/contact-sales` },
{ label: 'Conditions générales', href: `${marketplaceUrl}/footer/general-conditions` },
],
localeLabel: 'Europe (French)',
rightsLabel: 'Tous droits réservés.',
}
case 'ar':
return {
primary: [
{ label: 'من نحن', href: `${marketplaceUrl}/footer/about-us` },
{ label: 'شروط الاستخدام', href: `${marketplaceUrl}/footer/terms-of-service` },
{ label: 'الأمان', href: `${marketplaceUrl}/footer/security` },
{ label: 'الامتثال', href: `${marketplaceUrl}/footer/compliance` },
{ label: 'سياسة الخصوصية', href: appPrivacyHref.ar },
{ label: 'سياسة ملفات تعريف الارتباط', href: `${marketplaceUrl}/footer/cookie-policy` },
],
secondary: [
{ label: 'النشرة الإخبارية', href: `${marketplaceUrl}/footer/newsletter` },
{ label: 'تواصل مع المبيعات', href: `${marketplaceUrl}/footer/contact-sales` },
{ label: 'الشروط العامة', href: `${marketplaceUrl}/footer/general-conditions` },
],
localeLabel: 'North Africa (Arabic)',
rightsLabel: 'جميع الحقوق محفوظة.',
}
case 'en':
default:
return {
primary: [
{ label: 'About Us', href: `${marketplaceUrl}/footer/about-us` },
{ label: 'Terms of Service', href: `${marketplaceUrl}/footer/terms-of-service` },
{ label: 'Security', href: `${marketplaceUrl}/footer/security` },
{ label: 'Compliance', href: `${marketplaceUrl}/footer/compliance` },
{ label: 'Privacy Policy', href: appPrivacyHref.en },
{ label: 'Cookie Policy', href: `${marketplaceUrl}/footer/cookie-policy` },
],
secondary: [
{ label: 'Newsletter', href: `${marketplaceUrl}/footer/newsletter` },
{ label: 'Contact Sales', href: `${marketplaceUrl}/footer/contact-sales` },
{ label: 'General Conditions', href: `${marketplaceUrl}/footer/general-conditions` },
],
localeLabel: 'Global (English)',
rightsLabel: 'All rights reserved.',
}
}
}
export default function PublicFooter() {
const { language, setLanguage } = useDashboardI18n()
const footerContent = getFooterContent(language)
const availableLocaleOptions = localeOptions.filter((option) => option.value !== language)
const currentLocale = localeOptions.find((option) => option.value === language) ?? localeOptions[0]
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
return (
<footer className="border-t border-stone-200/80 bg-white/72 px-4 py-8 text-stone-600 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-blue-950/72 dark:text-stone-300">
<div className="mx-auto flex max-w-7xl flex-col items-center gap-5 px-0 text-center sm:px-2 lg:px-0">
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{footerContent.primary.map((item, index) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
{index < footerContent.primary.length - 1 ? (
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
) : null}
</div>
))}
</nav>
<div className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{footerContent.secondary.map((item) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
</div>
))}
<div ref={localeMenuRef} className="relative px-3">
<button
type="button"
onClick={() => setLocaleMenuOpen((open) => !open)}
className="inline-flex items-center gap-2 text-orange-700 transition hover:text-blue-900 dark:text-orange-300 dark:hover:text-stone-100"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<span aria-hidden="true" className="text-base leading-none">{currentLocale.flag}</span>
<span>{footerContent.localeLabel}</span>
<ChevronDown className={`h-4 w-4 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className="absolute left-1/2 top-full z-20 mt-3 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
{availableLocaleOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span aria-hidden="true" className="text-base leading-none">{option.flag}</span>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
<p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel}
</p>
</div>
</footer>
)
}
function FooterNavItem({ item }: { item: FooterItem }) {
const className = 'px-3 text-stone-600 transition hover:text-blue-900 dark:text-stone-300 dark:hover:text-white'
if (item.href) {
return (
<Link href={item.href} className={className}>
{item.label}
</Link>
)
}
return <span className={className}>{item.label}</span>
}
@@ -0,0 +1,230 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Image from 'next/image'
import { usePathname, useSearchParams } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
const languageMeta = {
en: { flag: '🇺🇸', shortLabel: 'EN' },
fr: { flag: '🇫🇷', shortLabel: 'FR' },
ar: { flag: '🇲🇦', shortLabel: 'AR' },
} as const
export default function PublicNavbar() {
const { language, setLanguage, theme, setTheme } = useDashboardI18n()
const pathname = usePathname()
const searchParams = useSearchParams()
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
const themeMenuRef = useRef<HTMLDivElement | null>(null)
const [themeMenuOpen, setThemeMenuOpen] = useState(false)
const currentLanguage = languageMeta[language]
const availableLanguages = (['en', 'fr', 'ar'] as const)
.filter((value) => value !== language)
.map((value) => ({ value, ...languageMeta[value] }))
const dict = {
en: {
home: 'Home',
marketplace: 'Marketplace',
signIn: 'Sign in',
createAccount: 'Create Agency Space',
theme: 'Theme',
light: 'Light',
dark: 'Dark',
},
fr: {
home: 'Accueil',
marketplace: 'Marketplace',
signIn: 'Connexion',
createAccount: 'Créer un espace dagence',
theme: 'Mode',
light: 'Clair',
dark: 'Sombre',
},
ar: {
home: 'الرئيسية',
marketplace: 'السوق',
signIn: 'تسجيل الدخول',
createAccount: 'إنشاء مساحة الوكالة',
theme: 'الوضع',
light: 'فاتح',
dark: 'داكن',
},
}[language]
const themeOptions = [
{ value: 'light' as const, label: dict.light },
{ value: 'dark' as const, label: dict.dark },
]
const currentTheme = themeOptions.find((option) => option.value === theme) ?? themeOptions[0]
const localeMenuPositionClass =
language === 'ar'
? 'right-0 sm:right-0'
: 'left-0 sm:left-auto sm:right-0'
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
if (!themeMenuRef.current?.contains(event.target as Node)) {
setThemeMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
function toggleLocaleMenu() {
setThemeMenuOpen(false)
setLocaleMenuOpen((open) => !open)
}
function toggleThemeMenu() {
setLocaleMenuOpen(false)
setThemeMenuOpen((open) => !open)
}
const signInParams = new URLSearchParams()
signInParams.set('lang', language)
signInParams.set('theme', theme)
const next = searchParams.get('next')
const redirect = searchParams.get('redirect')
if (next) signInParams.set('next', next)
if (redirect) signInParams.set('redirect', redirect)
const signInHref = `${marketplaceUrl}/sign-in?${signInParams.toString()}`
const signUpParams = new URLSearchParams()
signUpParams.set('lang', language)
signUpParams.set('theme', theme)
const signUpHref = `/dashboard/sign-up?${signUpParams.toString()}`
function navLinkClass(active: boolean) {
return `rounded-full px-2.5 py-1 text-[12px] font-medium transition sm:px-4 sm:py-2 sm:text-sm ${
active
? 'bg-blue-50 text-blue-950 ring-1 ring-blue-100 dark:bg-blue-900/55 dark:text-white dark:ring-blue-700/70'
: 'text-stone-600 hover:bg-stone-100 hover:text-blue-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100'
}`
}
const normalizedPathname = pathname.replace(/^\/dashboard(?=\/|$)/, '') || '/'
const signInActive = normalizedPathname === '/sign-in' || normalizedPathname.startsWith('/sign-in/')
const signUpActive = normalizedPathname === '/sign-up' || normalizedPathname.startsWith('/sign-up/')
return (
<header className="sticky top-0 z-30 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl shadow-[0_10px_30px_rgba(15,23,42,0.06)] transition-colors dark:border-blue-900 dark:bg-blue-950/76 dark:shadow-[0_10px_30px_rgba(0,0,0,0.28)]">
<div className="mx-auto flex max-w-7xl flex-col gap-1.5 px-4 py-1.5 sm:px-6 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:px-8 lg:py-2">
<a href={marketplaceUrl} className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-blue-950 dark:text-slate-100 sm:text-sm sm:tracking-[0.2em]">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={36}
height={36}
priority
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
/>
<span>RentalDriveGo</span>
</a>
<div className="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:gap-3">
<nav className="flex items-center gap-0.5 overflow-x-auto">
<a href={marketplaceUrl} className={navLinkClass(false)}>
{dict.home}
</a>
<a href={`${marketplaceUrl}/explore`} className={navLinkClass(false)}>
{dict.marketplace}
</a>
<a href={signInHref} className={navLinkClass(signInActive)} aria-current={signInActive ? 'page' : undefined}>
{dict.signIn}
</a>
<a
href={signUpHref}
aria-current={signUpActive ? 'page' : undefined}
className={`ml-1 rounded-full px-3 py-1 text-[12px] font-semibold transition sm:ml-2 sm:px-5 sm:py-2 sm:text-sm ${
signUpActive
? 'bg-orange-700 text-white ring-2 ring-orange-200 dark:bg-orange-300 dark:text-blue-950 dark:ring-orange-700/50'
: 'bg-blue-900 text-white hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300'
}`}
>
{dict.createAccount}
</a>
</nav>
<div className="shrink-0">
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/85 sm:self-auto sm:p-1">
<div ref={localeMenuRef} className="relative">
<button
type="button"
onClick={toggleLocaleMenu}
className="inline-flex min-w-[4.75rem] items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 text-[11px] font-semibold text-stone-700 transition hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-blue-900/40 sm:min-w-[5.25rem] sm:px-3 sm:py-2 sm:text-xs"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<span aria-hidden="true">{currentLanguage.flag}</span>
<span>{currentLanguage.shortLabel}</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className={`absolute top-full z-30 mt-2 w-48 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${localeMenuPositionClass}`}>
{availableLanguages.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span aria-hidden="true">{option.flag}</span>
<span>{option.shortLabel}</span>
</button>
))}
</div>
) : null}
</div>
<span className="mx-1 h-6 w-px bg-stone-200 dark:bg-stone-700" aria-hidden="true" />
<div ref={themeMenuRef} className="relative">
<button
type="button"
onClick={toggleThemeMenu}
className="inline-flex items-center gap-1.5 rounded-full px-2 py-1 transition hover:bg-stone-100 dark:hover:bg-blue-900/40 sm:px-2.5 sm:py-1.5"
aria-expanded={themeMenuOpen}
aria-haspopup="menu"
>
<span className="rounded-full bg-blue-900 px-3 py-1 text-[11px] font-semibold text-white dark:bg-orange-400 dark:text-white sm:px-3.5 sm:py-1.5 sm:text-xs">
{currentTheme.label}
</span>
<ChevronDown className={`h-3.5 w-3.5 text-stone-500 transition-transform dark:text-stone-400 ${themeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{themeMenuOpen ? (
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${localeMenuPositionClass}`}>
{themeOptions
.filter((option) => option.value !== theme)
.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setTheme(option.value)
setThemeMenuOpen(false)
}}
className="flex w-full items-center justify-between px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
</div>
</div>
</div>
</header>
)
}
@@ -0,0 +1,33 @@
import React, { isValidElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
vi.mock('./PublicNavbar', () => ({ default: function MockPublicNavbar() { return React.createElement('mock-navbar') } }))
vi.mock('./PublicFooter', () => ({ default: function MockPublicFooter() { return React.createElement('mock-footer') } }))
import PublicPageLayout from './PublicPageLayout'
function childTypes(node: React.ReactElement): string[] {
return React.Children.toArray(node.props.children).filter(isValidElement).map((child) => {
const type = child.type as any
return typeof type === 'string' ? type : type.displayName ?? type.name ?? 'anonymous'
})
}
describe('PublicPageLayout', () => {
it('provides reusable public chrome for sign-in and account creation pages', () => {
const layout = PublicPageLayout({ children: React.createElement('main') })
expect(childTypes(layout)).toEqual(['MockPublicNavbar', 'div', 'MockPublicFooter'])
expect(layout.props['data-public-page-layout']).toBe('true')
})
it('allows either public chrome region to be disabled independently', () => {
const layout = PublicPageLayout({
children: React.createElement('main'),
showNavbar: false,
showFooter: true,
})
expect(childTypes(layout)).toEqual(['div', 'MockPublicFooter'])
})
})
@@ -0,0 +1,40 @@
'use client'
import type { ReactNode } from 'react'
import PublicFooter from './PublicFooter'
import PublicNavbar from './PublicNavbar'
export type PublicPageLayoutProps = {
children: ReactNode
embedded?: boolean
showNavbar?: boolean
showFooter?: boolean
className?: string
contentClassName?: string
}
/**
* Shared frame for dashboard pages that live outside the authenticated app shell.
* Use `embedded` for iframe/partner surfaces that must not render nested chrome.
*/
export default function PublicPageLayout({
children,
embedded = false,
showNavbar = true,
showFooter = true,
className = '',
contentClassName = '',
}: PublicPageLayoutProps) {
return (
<div
className={`flex min-h-screen flex-col bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-blue-950 transition-colors dark:bg-[linear-gradient(180deg,#0f1f4a_0%,#112d6e_35%,#0d1f4f_100%)] dark:text-slate-100 ${className}`.trim()}
data-public-page-layout="true"
data-embedded={embedded ? 'true' : 'false'}
>
{!embedded && showNavbar ? <PublicNavbar /> : null}
<div className={`flex flex-1 flex-col ${contentClassName}`.trim()}>{children}</div>
{!embedded && showFooter ? <PublicFooter /> : null}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
'use client'
export { default as PublicFooter } from './PublicFooter'
export { default as PublicNavbar } from './PublicNavbar'
export { default as PublicPageLayout } from './PublicPageLayout'
export type { PublicPageLayoutProps } from './PublicPageLayout'
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import {
buildInspectionSavePayload,
comparisonPointsForCheckout,
editableDamagePointsForInspection,
pointKey,
type DamageInspection,
type DamagePoint,
} from './DamageInspectionCard'
function point(overrides: Partial<DamagePoint> = {}): DamagePoint {
return {
viewType: 'TOP',
x: 10,
y: 20,
damageType: 'SCRATCH',
severity: 'MINOR',
description: null,
isPreExisting: false,
...overrides,
}
}
function inspection(overrides: Partial<DamageInspection> = {}): DamageInspection {
return {
id: 'inspection-1',
type: 'CHECKOUT',
mileage: 45000,
fuelLevel: 'FULL',
fuelCharge: null,
generalCondition: 'Clean',
employeeNotes: 'No issues',
customerAgreed: true,
damagePoints: [],
...overrides,
}
}
describe('DamageInspectionCard helpers', () => {
it('builds stable duplicate keys using rounded coordinates and damage metadata', () => {
expect(pointKey(point({ x: 12.3456, y: 78.994 }))).toBe('TOP:12.35:78.99:SCRATCH:MINOR')
expect(pointKey(point({ viewType: 'LEFT', damageType: 'DENT', severity: 'MAJOR' }))).toBe('LEFT:10.00:20.00:DENT:MAJOR')
})
it('deduplicates checkout comparison points against stored pre-existing points', () => {
const duplicate = point({ id: 'cmp-1', isPreExisting: true, description: 'old scratch' })
const storedDuplicate = point({ id: 'stored-1', isPreExisting: true, description: 'stored copy' })
const storedUnique = point({ id: 'stored-2', viewType: 'REAR', x: 33, y: 44, isPreExisting: true })
const result = comparisonPointsForCheckout(
inspection({ damagePoints: [storedDuplicate, storedUnique] }),
[duplicate]
)
expect(result).toEqual([duplicate, storedUnique])
})
it('keeps checkout editing focused on new damage while check-in exposes all points', () => {
const oldDamage = point({ id: 'old', isPreExisting: true })
const newDamage = point({ id: 'new', isPreExisting: false })
const record = inspection({ damagePoints: [oldDamage, newDamage] })
expect(editableDamagePointsForInspection('CHECKOUT', record)).toEqual([newDamage])
expect(editableDamagePointsForInspection('CHECKIN', record)).toEqual([oldDamage, newDamage])
})
it('builds compact save payloads and omits nullable/empty fields', () => {
const payload = buildInspectionSavePayload(
inspection({
mileage: null,
fuelCharge: null,
generalCondition: '',
employeeNotes: '',
customerAgreed: false,
damagePoints: [point({ description: '' })],
}),
[point({ id: 'pre', viewType: 'FRONT', description: 'pre-existing chip', isPreExisting: true })]
)
expect(payload).toEqual({
fuelLevel: 'FULL',
customerAgreed: false,
damagePoints: [
{
viewType: 'FRONT',
x: 10,
y: 20,
damageType: 'SCRATCH',
severity: 'MINOR',
description: 'pre-existing chip',
isPreExisting: true,
},
{
viewType: 'TOP',
x: 10,
y: 20,
damageType: 'SCRATCH',
severity: 'MINOR',
isPreExisting: false,
},
],
})
})
})
@@ -0,0 +1,303 @@
'use client'
import { useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import VehicleConditionSheet from './VehicleConditionSheet'
type InspectionType = 'CHECKIN' | 'CHECKOUT'
type FuelLevel = 'FULL' | 'SEVEN_EIGHTHS' | 'THREE_QUARTERS' | 'FIVE_EIGHTHS' | 'HALF' | 'THREE_EIGHTHS' | 'QUARTER' | 'ONE_EIGHTH' | 'EMPTY'
type DamageType = 'SCRATCH' | 'DENT' | 'CRACK' | 'CHIP' | 'MISSING' | 'STAIN' | 'OTHER'
type DamageSeverity = 'MINOR' | 'MODERATE' | 'MAJOR'
export interface DamagePoint {
id?: string
viewType: 'TOP' | 'FRONT' | 'REAR' | 'LEFT' | 'RIGHT'
x: number
y: number
damageType: DamageType
severity: DamageSeverity
description?: string | null
isPreExisting: boolean
}
export interface DamageInspection {
id: string
type: InspectionType
mileage: number | null
fuelLevel: FuelLevel
fuelCharge: number | null
generalCondition: string | null
employeeNotes: string | null
customerAgreed: boolean
damagePoints: DamagePoint[]
}
const fuelLevels: FuelLevel[] = ['FULL', 'SEVEN_EIGHTHS', 'THREE_QUARTERS', 'FIVE_EIGHTHS', 'HALF', 'THREE_EIGHTHS', 'QUARTER', 'ONE_EIGHTH', 'EMPTY']
const damageTypes: DamageType[] = ['SCRATCH', 'DENT', 'CRACK', 'CHIP', 'MISSING', 'STAIN', 'OTHER']
const severityColors: Record<DamageSeverity, string> = {
MINOR: '#f59e0b',
MODERATE: '#f97316',
MAJOR: '#dc2626',
}
export function pointKey(point: Pick<DamagePoint, 'viewType' | 'x' | 'y' | 'damageType' | 'severity'>) {
return `${point.viewType}:${point.x.toFixed(2)}:${point.y.toFixed(2)}:${point.damageType}:${point.severity}`
}
export function comparisonPointsForCheckout(initialInspection: DamageInspection | null | undefined, comparisonPoints: DamagePoint[]): DamagePoint[] {
const storedPreExistingPoints = initialInspection?.damagePoints?.filter((point) => point.isPreExisting) ?? []
return [...comparisonPoints, ...storedPreExistingPoints].filter((point, index, all) => all.findIndex((item) => pointKey(item) === pointKey(point)) === index)
}
export function editableDamagePointsForInspection(type: InspectionType, initialInspection: DamageInspection | null | undefined): DamagePoint[] {
const points = initialInspection?.damagePoints ?? []
return type === 'CHECKOUT' ? points.filter((point) => !point.isPreExisting) : points
}
export function buildInspectionSavePayload(inspection: DamageInspection, overlayComparisonPoints: DamagePoint[]) {
const payloadPoints = [
...overlayComparisonPoints.map(({ viewType, x, y, damageType, severity, description }) => ({
viewType,
x,
y,
damageType,
severity,
...(description ? { description } : {}),
isPreExisting: true,
})),
...inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
viewType,
x,
y,
damageType,
severity,
...(description ? { description } : {}),
isPreExisting,
})),
]
return {
...(inspection.mileage !== null ? { mileage: inspection.mileage } : {}),
fuelLevel: inspection.fuelLevel,
...(inspection.fuelCharge !== null ? { fuelCharge: inspection.fuelCharge } : {}),
...(inspection.generalCondition ? { generalCondition: inspection.generalCondition } : {}),
...(inspection.employeeNotes ? { employeeNotes: inspection.employeeNotes } : {}),
customerAgreed: inspection.customerAgreed,
damagePoints: payloadPoints,
}
}
export default function DamageInspectionCard({
reservationId,
type,
initialInspection,
comparisonPoints = [],
editable = true,
readOnlyMessage,
onSaved,
}: {
reservationId: string
type: InspectionType
initialInspection?: DamageInspection | null
comparisonPoints?: DamagePoint[]
editable?: boolean
readOnlyMessage?: string | null
onSaved: (inspection: DamageInspection) => void
}) {
const { dict } = useDashboardI18n()
const r = dict.reservations
const overlayComparisonPoints = type === 'CHECKOUT'
? comparisonPointsForCheckout(initialInspection, comparisonPoints)
: []
const [inspection, setInspection] = useState<DamageInspection>({
id: initialInspection?.id ?? `${type.toLowerCase()}-draft`,
type,
mileage: initialInspection?.mileage ?? null,
fuelLevel: initialInspection?.fuelLevel ?? 'FULL',
fuelCharge: initialInspection?.fuelCharge ?? null,
generalCondition: initialInspection?.generalCondition ?? '',
employeeNotes: initialInspection?.employeeNotes ?? '',
customerAgreed: initialInspection?.customerAgreed ?? false,
damagePoints: editableDamagePointsForInspection(type, initialInspection),
})
const [damageType, setDamageType] = useState<DamageType>('SCRATCH')
const [severity, setSeverity] = useState<DamageSeverity>('MINOR')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
async function saveInspection() {
setSaving(true)
setError(null)
try {
const payload = buildInspectionSavePayload(inspection, overlayComparisonPoints)
const result = await apiFetch<DamageInspection>(`/reservations/${reservationId}/inspections/${type.toLowerCase()}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
setInspection({
...result,
damagePoints: type === 'CHECKOUT'
? result.damagePoints.filter((point) => !point.isPreExisting)
: result.damagePoints,
})
onSaved(result)
} catch (err: any) {
setError(err.message ?? r.failedSaveInspection)
} finally {
setSaving(false)
}
}
function addDamagePoint(viewType: DamagePoint['viewType'], x: number, y: number) {
setInspection((current) => ({
...current,
damagePoints: [
...current.damagePoints,
{ viewType, x, y, damageType, severity, description: '', isPreExisting: type === 'CHECKIN' },
],
}))
}
function removePoint(index: number) {
setInspection((current) => ({
...current,
damagePoints: current.damagePoints.filter((_, i) => i !== index),
}))
}
return (
<div className="card p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">{r.inspectionCardTitle(type)}</h3>
<p className="mt-1 text-sm text-slate-500">{r.inspectionSubtitle}</p>
</div>
<button onClick={saveInspection} disabled={saving || !editable} className="btn-primary">
{saving ? r.savingInspection : r.saveInspection}
</button>
</div>
{error && <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
{!editable && readOnlyMessage && (
<div className="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
{readOnlyMessage}
</div>
)}
<div className="mt-5 grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
{damageTypes.map((option) => (
<button
key={option}
type="button"
onClick={() => setDamageType(option)}
disabled={!editable}
className={`rounded-full px-3 py-1.5 font-semibold ${damageType === option ? 'bg-blue-700 text-white' : 'bg-white text-slate-600'}`}
>
{r.damageTypeLabels[option]}
</button>
))}
</div>
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
{(Object.keys(severityColors) as DamageSeverity[]).map((option) => (
<button
key={option}
type="button"
onClick={() => setSeverity(option)}
disabled={!editable}
className="rounded-full px-3 py-1.5 font-semibold text-white"
style={{ backgroundColor: severityColors[option], opacity: severity === option ? 1 : 0.55 }}
>
{r.severityLabels[option]}
</button>
))}
</div>
{type === 'CHECKOUT' && overlayComparisonPoints.length > 0 && (
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-2xl border border-slate-200 bg-slate-100 px-3 py-2 text-xs text-slate-600">
<span className="inline-flex items-center gap-2">
<span className="h-3.5 w-3.5 rounded-full border-2 border-dashed border-slate-500 bg-white" />
{r.inspectionCardTitle('CHECKIN')}
</span>
<span className="inline-flex items-center gap-2">
<span className="h-3.5 w-3.5 rounded-full bg-blue-700" />
{r.inspectionCardTitle('CHECKOUT')}
</span>
</div>
)}
<VehicleConditionSheet
points={inspection.damagePoints}
comparisonPoints={overlayComparisonPoints}
interactive={editable}
onAddPoint={editable ? addDamagePoint : undefined}
className={`w-full rounded-2xl border border-slate-200 bg-white ${editable ? 'cursor-crosshair' : 'cursor-default'}`}
/>
<p className="mt-2 text-xs text-slate-500">{r.diagramHelp}</p>
</div>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.mileageLabel}</label>
<input
type="number"
className="input-field"
value={inspection.mileage ?? ''}
disabled={!editable}
onChange={(e) => setInspection((cur) => ({ ...cur, mileage: e.target.value ? Number(e.target.value) : null }))}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.fuelLevelLabel}</label>
<select className="input-field" value={inspection.fuelLevel} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, fuelLevel: e.target.value as FuelLevel }))}>
{fuelLevels.map((fl) => <option key={fl} value={fl}>{r.fuelLevels[fl]}</option>)}
</select>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.generalConditionLabel}</label>
<textarea className="input-field min-h-24" value={inspection.generalCondition ?? ''} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, generalCondition: e.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.employeeNotesLabel}</label>
<textarea className="input-field min-h-24" value={inspection.employeeNotes ?? ''} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, employeeNotes: e.target.value }))} />
</div>
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
<input type="checkbox" checked={inspection.customerAgreed} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, customerAgreed: e.target.checked }))} />
{r.customerAcknowledged}
</label>
<div className="rounded-2xl border border-slate-200">
<div className="border-b border-slate-200 px-4 py-3">
<p className="text-sm font-semibold text-slate-900">{r.damageMarkersHeading}</p>
</div>
<div className="divide-y divide-slate-100">
{inspection.damagePoints.map((point, index) => (
<div key={`${point.x}-${point.y}-${index}`} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div>
<p className="font-medium text-slate-900">{r.damageTypeLabels[point.damageType]} · {r.severityLabels[point.severity]}</p>
<p className="text-xs text-slate-500">{point.viewType} · x {point.x.toFixed(1)} · y {point.y.toFixed(1)}</p>
</div>
<button type="button" onClick={() => removePoint(index)} disabled={!editable} className="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 disabled:cursor-not-allowed disabled:opacity-50">
{r.removeMarker}
</button>
</div>
))}
{inspection.damagePoints.length === 0 && (
<div className="px-4 py-6 text-sm text-slate-400">{r.noMarkers}</div>
)}
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,124 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { apiFetch } from '@/lib/api'
interface ReservationPhoto {
id: string
type: 'PICKUP' | 'DROPOFF'
url: string
createdAt: string
}
interface Props {
reservationId: string
type: 'PICKUP' | 'DROPOFF'
editable: boolean
title: string
readOnlyMessage?: string
}
export default function ReservationPhotoSection({ reservationId, type, editable, title, readOnlyMessage }: Props) {
const [photos, setPhotos] = useState<ReservationPhoto[]>([])
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const loadPhotos = useCallback(async () => {
try {
const all = await apiFetch<ReservationPhoto[]>(`/reservations/${reservationId}/photos`)
setPhotos(all.filter((p) => p.type === type))
} catch {
// silent — don't block the page if photos fail to load
}
}, [reservationId, type])
useEffect(() => { loadPhotos() }, [loadPhotos])
async function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return
setUploading(true)
setError(null)
try {
for (const file of Array.from(files)) {
const body = new FormData()
body.append('photo', file)
body.append('type', type)
await apiFetch(`/reservations/${reservationId}/photos`, { method: 'POST', body })
}
await loadPhotos()
} catch (err: any) {
setError(err.message ?? 'Upload failed')
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
async function handleDelete(photoId: string) {
setError(null)
try {
await apiFetch(`/reservations/${reservationId}/photos/${photoId}`, { method: 'DELETE' })
setPhotos((prev) => prev.filter((p) => p.id !== photoId))
} catch (err: any) {
setError(err.message ?? 'Delete failed')
}
}
return (
<div className="card p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">{title}</h3>
{editable && (
<button
type="button"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
className="btn-secondary text-xs"
>
{uploading ? 'Uploading…' : '+ Add photos'}
</button>
)}
</div>
{!editable && readOnlyMessage && (
<p className="mb-4 text-xs text-slate-500">{readOnlyMessage}</p>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
{error && <p className="mb-3 text-xs text-red-600">{error}</p>}
{photos.length === 0 ? (
<p className="text-sm text-slate-400">No photos yet.</p>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{photos.map((photo) => (
<div key={photo.id} className="group relative aspect-square overflow-hidden rounded-xl border border-slate-200">
<img src={photo.url} alt="" className="h-full w-full object-cover" />
{editable && (
<button
type="button"
onClick={() => handleDelete(photo.id)}
className="absolute right-1 top-1 hidden h-6 w-6 items-center justify-center rounded-full bg-black/60 text-white group-hover:flex"
aria-label="Delete photo"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="h-3 w-3">
<path d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z" />
</svg>
</button>
)}
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import {
SHEET_HEIGHT,
SHEET_WIDTH,
resolvePointPosition,
resolveVehicleSheetClick,
severityColors,
viewBoxes,
type VehicleSheetPoint,
} from './VehicleConditionSheet'
const rect = { left: 10, top: 20, width: SHEET_WIDTH * 2, height: SHEET_HEIGHT * 2 } as Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>
function sheetClientPoint(x: number, y: number) {
return {
clientX: rect.left + (x / SHEET_WIDTH) * rect.width,
clientY: rect.top + (y / SHEET_HEIGHT) * rect.height,
}
}
describe('VehicleConditionSheet geometry helpers', () => {
it('maps percentage positions into absolute sheet coordinates per view', () => {
expect(resolvePointPosition({ viewType: 'TOP', x: 50, y: 50, severity: 'MINOR' })).toEqual({
cx: viewBoxes.TOP.x + viewBoxes.TOP.w / 2,
cy: viewBoxes.TOP.y + viewBoxes.TOP.h / 2,
})
expect(resolvePointPosition({ viewType: 'LEFT', x: 0, y: 100, severity: 'MAJOR' })).toEqual({
cx: viewBoxes.LEFT.x,
cy: viewBoxes.LEFT.y + viewBoxes.LEFT.h,
})
})
it('defaults missing viewType to TOP for legacy damage points', () => {
const legacyPoint = { x: 25, y: 10, severity: 'MODERATE' } satisfies VehicleSheetPoint
expect(resolvePointPosition(legacyPoint)).toEqual({
cx: viewBoxes.TOP.x + viewBoxes.TOP.w * 0.25,
cy: viewBoxes.TOP.y + viewBoxes.TOP.h * 0.1,
})
})
it('resolves clicks inside each vehicle region to local percentages', () => {
const topCenter = sheetClientPoint(viewBoxes.TOP.x + viewBoxes.TOP.w / 2, viewBoxes.TOP.y + viewBoxes.TOP.h / 2)
const frontCorner = sheetClientPoint(viewBoxes.FRONT.x, viewBoxes.FRONT.y)
const rearCorner = sheetClientPoint(viewBoxes.REAR.x + viewBoxes.REAR.w, viewBoxes.REAR.y + viewBoxes.REAR.h)
expect(resolveVehicleSheetClick(topCenter.clientX, topCenter.clientY, rect)).toEqual({ viewType: 'TOP', x: 50, y: 50 })
expect(resolveVehicleSheetClick(frontCorner.clientX, frontCorner.clientY, rect)).toEqual({ viewType: 'FRONT', x: 0, y: 0 })
expect(resolveVehicleSheetClick(rearCorner.clientX, rearCorner.clientY, rect)).toEqual({ viewType: 'REAR', x: 100, y: 100 })
})
it('rejects clicks outside vehicle regions instead of creating impossible damage points', () => {
const outside = sheetClientPoint(40, 40)
expect(resolveVehicleSheetClick(outside.clientX, outside.clientY, rect)).toBeNull()
})
it('keeps severity color mapping explicit for rendering and comparison markers', () => {
expect(severityColors).toEqual({
MINOR: '#f59e0b',
MODERATE: '#f97316',
MAJOR: '#dc2626',
})
})
})
@@ -0,0 +1,119 @@
'use client'
export type VehicleSheetView = 'TOP' | 'FRONT' | 'REAR' | 'LEFT' | 'RIGHT'
export type VehicleSheetPoint = {
viewType?: VehicleSheetView
x: number
y: number
severity: 'MINOR' | 'MODERATE' | 'MAJOR'
}
export const SHEET_WIDTH = 573
export const SHEET_HEIGHT = 876
const SHEET_IMAGE_PATH = '/dashboard/vehicle-condition-template.png'
export const severityColors: Record<VehicleSheetPoint['severity'], string> = {
MINOR: '#f59e0b',
MODERATE: '#f97316',
MAJOR: '#dc2626',
}
export const viewBoxes: Record<VehicleSheetView, { x: number; y: number; w: number; h: number }> = {
FRONT: { x: 161, y: 4, w: 251, h: 108 },
TOP: { x: 150, y: 112, w: 273, h: 613 },
REAR: { x: 156, y: 724, w: 252, h: 123 },
LEFT: { x: 4, y: 114, w: 146, h: 618 },
RIGHT: { x: 423, y: 114, w: 146, h: 618 },
}
export function resolvePointPosition(point: VehicleSheetPoint) {
const viewType = point.viewType ?? 'TOP'
const box = viewBoxes[viewType]
return {
cx: box.x + (point.x / 100) * box.w,
cy: box.y + (point.y / 100) * box.h,
}
}
export function resolveVehicleSheetClick(clientX: number, clientY: number, rect: Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>): { viewType: VehicleSheetView; x: number; y: number } | null {
const x = ((clientX - rect.left) / rect.width) * SHEET_WIDTH
const y = ((clientY - rect.top) / rect.height) * SHEET_HEIGHT
const target = (Object.entries(viewBoxes) as Array<[VehicleSheetView, { x: number; y: number; w: number; h: number }]>).find(([, box]) =>
x >= box.x && x <= box.x + box.w && y >= box.y && y <= box.y + box.h
)
if (!target) return null
const [viewType, box] = target
return {
viewType,
x: ((x - box.x) / box.w) * 100,
y: ((y - box.y) / box.h) * 100,
}
}
function renderMarker(point: VehicleSheetPoint, index: number, variant: 'current' | 'comparison') {
const { cx, cy } = resolvePointPosition(point)
const color = severityColors[point.severity]
if (variant === 'comparison') {
return (
<g key={`comparison-${point.viewType ?? 'TOP'}-${point.x}-${point.y}-${index}`}>
<circle cx={cx} cy={cy} r="9" fill="rgba(255,255,255,0.92)" stroke={color} strokeWidth="2.5" strokeDasharray="5 4" />
<circle cx={cx} cy={cy} r="4" fill={color} opacity="0.28" />
</g>
)
}
return (
<circle
key={`current-${point.viewType ?? 'TOP'}-${point.x}-${point.y}-${index}`}
cx={cx}
cy={cy}
r="7.5"
fill={color}
stroke="white"
strokeWidth="2.5"
/>
)
}
export default function VehicleConditionSheet({
points,
comparisonPoints = [],
interactive = false,
className = '',
onAddPoint,
}: {
points: VehicleSheetPoint[]
comparisonPoints?: VehicleSheetPoint[]
interactive?: boolean
className?: string
onAddPoint?: (viewType: VehicleSheetView, x: number, y: number) => void
}) {
function handleClick(event: React.MouseEvent<SVGSVGElement>) {
if (!interactive || !onAddPoint) return
const resolved = resolveVehicleSheetClick(event.clientX, event.clientY, event.currentTarget.getBoundingClientRect())
if (!resolved) return
onAddPoint(resolved.viewType, resolved.x, resolved.y)
}
return (
<svg
viewBox={`0 0 ${SHEET_WIDTH} ${SHEET_HEIGHT}`}
className={className}
onClick={handleClick}
>
<rect x="1.5" y="1.5" width={SHEET_WIDTH - 3} height={SHEET_HEIGHT - 3} rx="18" fill="#fff" stroke="#cbd5e1" strokeWidth="3" />
<image href={SHEET_IMAGE_PATH} x="0" y="0" width={SHEET_WIDTH} height={SHEET_HEIGHT} preserveAspectRatio="xMidYMid meet" />
{comparisonPoints.map((point, index) => renderMarker(point, index, 'comparison'))}
{points.map((point, index) => renderMarker(point, index, 'current'))}
</svg>
)
}
@@ -0,0 +1,339 @@
'use client'
import { useState, useEffect } from 'react'
import type { TeamMember } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
interface Props {
member: TeamMember | null
open: boolean
onClose: () => void
onUpdateRole: (memberId: string, role: 'MANAGER' | 'AGENT') => Promise<void>
onDeactivate: (memberId: string) => Promise<void>
onReactivate: (memberId: string) => Promise<void>
onRemove: (memberId: string) => Promise<void>
}
type ActionState = 'idle' | 'saving' | 'deactivating' | 'reactivating' | 'removing'
type ConfirmAction = 'deactivate' | 'reactivate' | 'remove' | null
const copy = {
en: {
pendingInvite: 'Pending invite',
deactivated: 'Deactivated',
role: 'Role',
cancel: 'Cancel',
saveChanges: 'Save changes',
saving: 'Saving…',
working: 'Working…',
deactivate: 'Deactivate',
reactivate: 'Reactivate',
remove: 'Remove',
removePermanently: 'Remove permanently',
confirmDeactivateTitle: 'Deactivate this member?',
confirmRemoveTitle: 'Permanently remove this member?',
confirmReactivateTitle: 'Reactivate this member?',
confirmDeactivateDesc: "They'll immediately lose dashboard access. You can reactivate anytime.",
confirmRemoveDesc: "This will delete the employee record and revoke their dashboard access. This cannot be undone.",
confirmReactivateDesc: "They'll regain dashboard access with their current role.",
roleOptions: [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Full ops — no billing' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Reservations & check-ins' },
],
},
fr: {
pendingInvite: 'Invitation en attente',
deactivated: 'Désactivé',
role: 'Rôle',
cancel: 'Annuler',
saveChanges: 'Enregistrer',
saving: 'Enregistrement…',
working: 'En cours…',
deactivate: 'Désactiver',
reactivate: 'Réactiver',
remove: 'Supprimer',
removePermanently: 'Supprimer définitivement',
confirmDeactivateTitle: 'Désactiver ce membre ?',
confirmRemoveTitle: 'Supprimer définitivement ce membre ?',
confirmReactivateTitle: 'Réactiver ce membre ?',
confirmDeactivateDesc: "Il perdra immédiatement l'accès au tableau de bord. Vous pouvez le réactiver à tout moment.",
confirmRemoveDesc: "Cela supprimera le compte et révoquera son accès au tableau de bord. Cette action est irréversible.",
confirmReactivateDesc: "Il retrouvera l'accès au tableau de bord avec son rôle actuel.",
roleOptions: [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Opérations complètes — sans facturation' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Réservations et enregistrements' },
],
},
ar: {
pendingInvite: 'دعوة قيد الانتظار',
deactivated: 'معطل',
role: 'الدور',
cancel: 'إلغاء',
saveChanges: 'حفظ التغييرات',
saving: 'جارٍ الحفظ…',
working: 'جارٍ التنفيذ…',
deactivate: 'تعطيل',
reactivate: 'إعادة التفعيل',
remove: 'حذف',
removePermanently: 'حذف نهائياً',
confirmDeactivateTitle: 'تعطيل هذا العضو؟',
confirmRemoveTitle: 'حذف هذا العضو نهائياً؟',
confirmReactivateTitle: 'إعادة تفعيل هذا العضو؟',
confirmDeactivateDesc: 'سيفقد الوصول إلى لوحة التحكم فوراً. يمكنك إعادة التفعيل في أي وقت.',
confirmRemoveDesc: 'سيتم حذف سجل الموظف وإلغاء صلاحياته. هذا الإجراء لا يمكن التراجع عنه.',
confirmReactivateDesc: 'سيستعيد الوصول إلى لوحة التحكم بدوره الحالي.',
roleOptions: [
{ value: 'MANAGER' as const, label: 'مدير', description: 'عمليات كاملة — بدون فوترة' },
{ value: 'AGENT' as const, label: 'وكيل', description: 'الحجوزات والاستلام' },
],
},
}
export default function EditMemberModal({
member,
open,
onClose,
onUpdateRole,
onDeactivate,
onReactivate,
onRemove,
}: Props) {
const { language } = useDashboardI18n()
const t = copy[language]
const isRtl = language === 'ar'
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [actionState, setActionState] = useState<ActionState>('idle')
const [confirm, setConfirm] = useState<ConfirmAction>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (member && open) {
setRole(member.role === 'OWNER' ? 'MANAGER' : (member.role as 'MANAGER' | 'AGENT'))
setActionState('idle')
setConfirm(null)
setError(null)
}
}, [member, open])
if (!open || !member) return null
const isPending = member.invitationStatus === 'pending'
const isActive = member.isActive
const busy = actionState !== 'idle'
const initials = (member.firstName[0] ?? '') + (member.lastName[0] ?? '')
const handleSave = async () => {
if (role === member.role) { onClose(); return }
setActionState('saving')
setError(null)
try {
await onUpdateRole(member.id, role)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
}
const handleDeactivate = async () => {
setActionState('deactivating')
setError(null)
try {
await onDeactivate(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const handleReactivate = async () => {
setActionState('reactivating')
setError(null)
try {
await onReactivate(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const handleRemove = async () => {
setActionState('removing')
setError(null)
try {
await onRemove(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
>
<div
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
dir={isRtl ? 'rtl' : 'ltr'}
>
{/* Member header */}
<div className="flex items-center gap-3 mb-5 pb-5 border-b border-zinc-100 dark:border-zinc-800">
<div className="w-10 h-10 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-sm font-medium text-zinc-600 dark:text-zinc-300">
{initials}
</div>
<div>
<p className="font-medium text-zinc-900 dark:text-white text-sm">
{member.firstName} {member.lastName}
</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</p>
</div>
<div className="ml-auto">
{isPending && (
<span className="text-xs px-2 py-1 rounded-full bg-orange-50 dark:bg-orange-950/30 text-orange-700 dark:text-orange-400 border border-orange-100 dark:border-orange-900/50">
{t.pendingInvite}
</span>
)}
{!isPending && !isActive && (
<span className="text-xs px-2 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400">
{t.deactivated}
</span>
)}
</div>
</div>
{/* Confirm panel */}
{confirm && (
<div className="mb-4 px-4 py-3 rounded-xl border border-red-100 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20">
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">
{confirm === 'deactivate' && t.confirmDeactivateTitle}
{confirm === 'remove' && t.confirmRemoveTitle}
{confirm === 'reactivate' && t.confirmReactivateTitle}
</p>
<p className="text-xs text-red-600 dark:text-red-500 mb-3">
{confirm === 'deactivate' && t.confirmDeactivateDesc}
{confirm === 'remove' && t.confirmRemoveDesc}
{confirm === 'reactivate' && t.confirmReactivateDesc}
</p>
<div className={`flex gap-2 ${isRtl ? 'flex-row-reverse' : ''}`}>
<button
onClick={() => setConfirm(null)}
className="flex-1 text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
{t.cancel}
</button>
<button
onClick={
confirm === 'deactivate' ? handleDeactivate :
confirm === 'reactivate' ? handleReactivate :
handleRemove
}
disabled={busy}
className={[
'flex-1 text-xs px-3 py-1.5 rounded-lg font-medium disabled:opacity-50',
confirm === 'reactivate'
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'bg-red-600 text-white hover:bg-red-700',
].join(' ')}
>
{busy ? t.working : (
confirm === 'deactivate' ? t.deactivate :
confirm === 'reactivate' ? t.reactivate :
t.removePermanently
)}
</button>
</div>
</div>
)}
{/* Role selector — only for active, non-pending members */}
{!isPending && isActive && (
<div className="mb-4">
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">
{t.role}
</label>
<div className="grid grid-cols-2 gap-2">
{t.roleOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRole(option.value)}
className={[
'text-left px-3 py-2.5 rounded-xl border transition-all',
role === option.value
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
].join(' ')}
>
<div className="text-sm font-medium text-zinc-900 dark:text-white">{option.label}</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{option.description}</div>
</button>
))}
</div>
</div>
)}
{error && (
<div className="mb-4 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Actions footer */}
<div className={`flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800 ${isRtl ? 'flex-row-reverse' : ''}`}>
<div className={`flex gap-2 ${isRtl ? 'ml-auto' : 'mr-auto'}`}>
{isActive && !isPending && (
<button
onClick={() => setConfirm('deactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-red-200 dark:border-red-900/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
{t.deactivate}
</button>
)}
{!isActive && (
<button
onClick={() => setConfirm('reactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 transition-colors"
>
{t.reactivate}
</button>
)}
<button
onClick={() => setConfirm('remove')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
{t.remove}
</button>
</div>
<button
onClick={onClose}
disabled={busy}
className="px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors disabled:opacity-50"
>
{t.cancel}
</button>
{isActive && !isPending && (
<button
onClick={handleSave}
disabled={busy}
className="px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors disabled:opacity-50"
>
{actionState === 'saving' ? t.saving : t.saveChanges}
</button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,273 @@
'use client'
import { useState, useEffect } from 'react'
import type { InvitePayload } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
interface Props {
open: boolean
onClose: () => void
onInvite: (payload: InvitePayload) => Promise<void>
}
const copy = {
en: {
title: 'Invite a team member',
subtitle: "They'll receive an email invitation to join your workspace.",
firstName: 'First name',
lastName: 'Last name',
email: 'Email address',
role: 'Role',
cancel: 'Cancel',
sending: 'Sending…',
sendInvite: 'Send invite →',
failedInvite: 'Failed to send invitation',
more: (n: number) => `+${n} more`,
roleOptions: [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Full fleet operations, without billing or team settings',
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Day-to-day operations only',
permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'],
},
],
},
fr: {
title: 'Inviter un membre',
subtitle: "Cette personne recevra un e-mail d'invitation pour rejoindre votre espace de travail.",
firstName: 'Prénom',
lastName: 'Nom',
email: 'Adresse e-mail',
role: 'Rôle',
cancel: 'Annuler',
sending: 'Envoi…',
sendInvite: "Envoyer l'invitation →",
failedInvite: "Échec de l'envoi de l'invitation",
more: (n: number) => `+${n} de plus`,
roleOptions: [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Opérations complètes, sans accès à la facturation ni aux paramètres de l’équipe',
permissions: ['Véhicules', 'Réservations', 'CRM', 'Offres', 'Rapports', 'Validation des permis'],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Opérations quotidiennes uniquement',
permissions: ['Voir la flotte', 'Réservations', 'Départ / Retour', 'Voir les clients'],
},
],
},
ar: {
title: 'دعوة عضو في الفريق',
subtitle: 'سيتلقى رسالة بريد إلكتروني للانضمام إلى مساحة العمل.',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
role: 'الدور',
cancel: 'إلغاء',
sending: 'جارٍ الإرسال…',
sendInvite: '← إرسال الدعوة',
failedInvite: 'فشل إرسال الدعوة',
more: (n: number) => `+${n} أكثر`,
roleOptions: [
{
value: 'MANAGER' as const,
label: 'مدير',
description: 'صلاحيات تشغيل كاملة للأسطول، من دون الفوترة أو إعدادات الفريق',
permissions: ['المركبات', 'الحجوزات', 'CRM', 'العروض', 'التقارير', 'الموافقة على الرخص'],
},
{
value: 'AGENT' as const,
label: 'وكيل',
description: 'العمليات اليومية فقط',
permissions: ['عرض الأسطول', 'الحجوزات', 'الاستلام / الإرجاع', 'عرض العملاء'],
},
],
},
}
export default function InviteModal({ open, onClose, onInvite }: Props) {
const { language } = useDashboardI18n()
const t = copy[language]
const isRtl = language === 'ar'
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!open) {
setTimeout(() => {
setFirstName('')
setLastName('')
setEmail('')
setRole('AGENT')
setError(null)
}, 200)
}
}, [open])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
await onInvite({ firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim(), role })
onClose()
} catch (err: any) {
setError(err.message ?? t.failedInvite)
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
dir={isRtl ? 'rtl' : 'ltr'}
>
<div className="mb-5">
<h2 className="text-lg font-medium text-zinc-900 dark:text-white">{t.title}</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">{t.subtitle}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
{t.firstName}
</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Youssef"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:ring-offset-0 focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
{t.lastName}
</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Benali"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
{t.email}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="youssef@yourcompany.com"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
{t.role}
</label>
<div className="grid grid-cols-2 gap-2">
{t.roleOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRole(option.value)}
className={[
'text-left px-3 py-3 rounded-xl border transition-all',
role === option.value
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
].join(' ')}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-zinc-900 dark:text-white">
{option.label}
</span>
{role === option.value && (
<span className="w-4 h-4 rounded-full bg-zinc-900 dark:bg-white flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-white dark:text-zinc-900" fill="currentColor" viewBox="0 0 12 12">
<path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</span>
)}
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-snug">
{option.description}
</p>
<div className="mt-2 flex flex-wrap gap-1">
{option.permissions.slice(0, 3).map((p) => (
<span
key={p}
className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400"
>
{p}
</span>
))}
{option.permissions.length > 3 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400">
{t.more(option.permissions.length - 3)}
</span>
)}
</div>
</button>
))}
</div>
</div>
{error && (
<div className="px-3 py-2.5 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className={`flex gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800 ${isRtl ? 'flex-row-reverse' : ''}`}>
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
{t.cancel}
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? t.sending : t.sendInvite}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,225 @@
'use client'
import { useDashboardI18n } from '@/components/I18nProvider'
type Access = 'full' | 'partial' | 'none'
interface Feature {
manager: Access
managerNote?: string
agent: Access
agentNote?: string
}
const FEATURES: Feature[] = [
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'partial' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'partial' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'full' },
{ manager: 'none', agent: 'none' },
{ manager: 'none', agent: 'none' },
{ manager: 'none', agent: 'none' },
{ manager: 'none', agent: 'none' },
]
const copy = {
en: {
heading: 'Role permissions',
subtitle: 'What each role can do across the dashboard. Owners have full access to everything.',
colFeature: 'Feature',
colManager: 'Manager',
colAgent: 'Agent',
full: 'Full',
noAccess: 'No access',
limited: 'Limited',
featureLabels: [
'View fleet & vehicles',
'Add / edit vehicles',
'Publish / unpublish vehicle',
'Upload vehicle photos',
'View reservations',
'Create reservations',
'Cancel reservations',
'Check-in / check-out',
'Damage inspection',
'Customer CRM — view',
'Customer CRM — edit',
'Approve driver licenses',
'Flag / blacklist customer',
'Offers & promotions',
'Analytics & reports',
'Contract & invoice PDF',
'Billing & subscription',
'Invite / remove staff',
'Brand & site settings',
'Payment settings',
],
agentNotes: ['', '', '', '', '', '', 'Own only', '', '', '', 'Notes only', '', '', '', '', '', '', '', '', ''],
},
fr: {
heading: 'Permissions par rôle',
subtitle: 'Ce que chaque rôle peut faire dans lespace de travail. Les propriétaires ont un accès complet à lensemble du système.',
colFeature: 'Fonctionnalité',
colManager: 'Manager',
colAgent: 'Agent',
full: 'Complet',
noAccess: 'Aucun accès',
limited: 'Limité',
featureLabels: [
'Voir la flotte et les véhicules',
'Ajouter / modifier des véhicules',
'Publier / dépublier un véhicule',
'Téléverser des photos de véhicule',
'Voir les réservations',
'Créer des réservations',
'Annuler des réservations',
'Départ / retour',
'Inspection des dommages',
'CRM clients — consultation',
'CRM clients — modification',
'Approuver les permis de conduire',
'Signaler / bloquer un client',
'Offres et promotions',
'Analyses et rapports',
'Contrat et facture PDF',
'Facturation et abonnement',
'Inviter / supprimer des membres de l’équipe',
'Paramètres marque et site',
'Paramètres de paiement',
],
agentNotes: ['', '', '', '', '', '', 'Les siennes uniquement', '', '', '', 'Notes uniquement', '', '', '', '', '', '', '', '', ''],
},
ar: {
heading: 'صلاحيات الأدوار',
subtitle: 'ما يمكن لكل دور القيام به داخل مساحة العمل. يملك المالكون صلاحية كاملة على كل شيء.',
colFeature: 'الميزة',
colManager: 'المدير',
colAgent: 'الوكيل',
full: 'كامل',
noAccess: 'لا وصول',
limited: 'محدود',
featureLabels: [
'عرض الأسطول والمركبات',
'إضافة / تعديل المركبات',
'نشر / إلغاء نشر المركبة',
'رفع صور المركبة',
'عرض الحجوزات',
'إنشاء الحجوزات',
'إلغاء الحجوزات',
'الاستلام / الإرجاع',
'فحص الأضرار',
'إدارة العملاء — عرض',
'إدارة العملاء — تعديل',
'الموافقة على رخص القيادة',
'تمييز العميل / حظره',
'العروض والحملات الترويجية',
'التحليلات والتقارير',
'العقد والفاتورة بصيغة PDF',
'الفوترة والاشتراك',
'دعوة / إزالة الموظفين',
'إعدادات العلامة والموقع',
'إعدادات الدفع',
],
agentNotes: ['', '', '', '', '', '', 'حجوزاته فقط', '', '', '', 'الملاحظات فقط', '', '', '', '', '', '', '', '', ''],
},
}
function AccessCell({ access, label, limitedNote }: { access: Access; label: { full: string; noAccess: string; limited: string }; limitedNote: string }) {
if (access === 'full') {
return (
<div className="flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 14 14">
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="text-xs text-zinc-700 dark:text-zinc-300">{label.full}</span>
</div>
)
}
if (access === 'partial') {
return (
<div className="flex items-center gap-1.5">
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2.5 h-0.5 rounded bg-orange-500" />
</div>
<span className="text-xs text-orange-700 dark:text-orange-400">{limitedNote || label.limited}</span>
</div>
)
}
return (
<div className="flex items-center gap-1.5">
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2 h-0.5 rounded bg-zinc-300 dark:bg-zinc-600" />
</div>
<span className="text-xs text-zinc-400 dark:text-zinc-600">{label.noAccess}</span>
</div>
)
}
export default function PermissionsMatrix() {
const { language } = useDashboardI18n()
const t = copy[language]
return (
<div dir={language === 'ar' ? 'rtl' : 'ltr'}>
<div className="mb-4">
<h2 className="text-base font-medium text-zinc-900 dark:text-white">{t.heading}</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">{t.subtitle}</p>
</div>
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
<div className="grid grid-cols-[1fr_120px_120px] bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">
{t.colFeature}
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
{t.colManager}
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
{t.colAgent}
</div>
</div>
{FEATURES.map((feature, i) => (
<div
key={i}
className={[
'grid grid-cols-[1fr_120px_120px]',
i < FEATURES.length - 1 ? 'border-b border-zinc-100 dark:border-zinc-800' : '',
i % 2 === 0 ? '' : 'bg-zinc-50/50 dark:bg-zinc-800/20',
].join(' ')}
>
<div className="px-4 py-2.5 text-xs text-zinc-600 dark:text-zinc-400">
{t.featureLabels[i]}
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell
access={feature.manager}
label={{ full: t.full, noAccess: t.noAccess, limited: t.limited }}
limitedNote={feature.managerNote ?? ''}
/>
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell
access={feature.agent}
label={{ full: t.full, noAccess: t.noAccess, limited: t.limited }}
limitedNote={t.agentNotes[i] ?? ''}
/>
</div>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { bilingualPrimary, emptyBilingual } from './BilingualInput'
describe('bilingual field helpers', () => {
it('returns a stable empty field shape for French and Arabic labels', () => {
expect(emptyBilingual()).toEqual({ fr: '', ar: '' })
expect(emptyBilingual()).not.toBe(emptyBilingual())
})
it('prefers French as the primary display value when both translations exist', () => {
expect(bilingualPrimary({ fr: 'Assurance', ar: 'تأمين' })).toBe('Assurance')
})
it('falls back to Arabic when the French value is empty', () => {
expect(bilingualPrimary({ fr: '', ar: 'تأمين' })).toBe('تأمين')
})
it('does not trim values implicitly so form state remains lossless', () => {
expect(bilingualPrimary({ fr: ' Service premium ', ar: 'خدمة' })).toBe(' Service premium ')
expect(bilingualPrimary({ fr: '', ar: ' خدمة ' })).toBe(' خدمة ')
})
})
@@ -0,0 +1,66 @@
'use client'
export type BilingualField = { fr: string; ar: string }
export function emptyBilingual(): BilingualField {
return { fr: '', ar: '' }
}
export function bilingualPrimary(field: BilingualField): string {
return field.fr || field.ar
}
export function BilingualInput({
label,
value,
onChange,
required = false,
maxLength,
}: {
label: string
value: BilingualField
onChange: (value: BilingualField) => void
required?: boolean
maxLength?: number
}) {
return (
<div>
<span className="mb-1.5 block text-sm font-medium text-slate-700">
{label}
{required ? <span className="ml-1 text-red-600">*</span> : null}
</span>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-blue-500">
FR
</span>
<input
type="text"
dir="ltr"
lang="fr"
maxLength={maxLength}
placeholder="Français…"
value={value.fr}
onChange={(e) => onChange({ ...value, fr: e.target.value })}
className="input-field pl-11"
/>
</div>
<div className="relative">
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600">
AR
</span>
<input
type="text"
dir="rtl"
lang="ar"
maxLength={maxLength}
placeholder="بالعربية…"
value={value.ar}
onChange={(e) => onChange({ ...value, ar: e.target.value })}
className="input-field pr-11 text-right"
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,72 @@
import React, { isValidElement } from 'react'
import { describe, expect, it } from 'vitest'
import StatCard from './StatCard'
import type { LucideIcon } from 'lucide-react'
function collectText(node: unknown): string[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (typeof node === 'string' || typeof node === 'number') return [String(node)]
if (Array.isArray(node)) return node.flatMap(collectText)
if (isValidElement<{ children?: React.ReactNode }>(node)) return collectText(node.props.children)
return []
}
function collectElements(node: unknown): React.ReactElement[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (Array.isArray(node)) return node.flatMap(collectElements)
if (!isValidElement<{ children?: React.ReactNode }>(node)) return []
return [node, ...collectElements(node.props.children)]
}
function FakeIcon(props: { className?: string }) {
return React.createElement('svg', props)
}
const icon = FakeIcon as unknown as LucideIcon
describe('StatCard', () => {
it('renders title, numeric value, and positive changes with a plus sign', () => {
const element = StatCard({ title: 'Revenue', value: 12450, change: 8.238, icon })
const text = collectText(element)
const renderedText = text.join('')
expect(text).toContain('Revenue')
expect(text).toContain('12450')
expect(renderedText).toContain('+8.2%')
expect(text).toContain('vs last month')
})
it('renders negative changes without forcing a positive marker', () => {
const element = StatCard({ title: 'Cancellations', value: '12', change: -3.15, vsLastMonthLabel: 'vs previous period', icon })
const text = collectText(element)
const renderedText = text.join('')
expect(renderedText).toContain('-3.1%')
expect(text).toContain('vs previous period')
expect(renderedText).not.toContain('+-3.1')
})
it('omits the trend row when no change is provided', () => {
const element = StatCard({ title: 'Active cars', value: 31, icon })
const text = collectText(element)
expect(text).toContain('Active cars')
expect(text).toContain('31')
expect(text).not.toContain('vs last month')
expect(text.join('')).not.toContain('%')
})
it('passes custom icon color and background classes to the rendered icon shell', () => {
const element = StatCard({
title: 'Fleet',
value: 9,
icon,
iconColor: 'text-violet-700',
iconBg: 'bg-violet-50',
})
const elements = collectElements(element)
expect(elements.some((node) => String(node.props.className ?? '').includes('bg-violet-50'))).toBe(true)
expect(elements.some((node) => String(node.props.className ?? '').includes('text-violet-700'))).toBe(true)
})
})
+87
View File
@@ -0,0 +1,87 @@
import * as React from "react";
import { LucideIcon } from 'lucide-react'
interface StatCardProps {
title: string
value: string | number
change?: number
vsLastMonthLabel?: string
icon: LucideIcon
iconColor?: string
iconBg?: string
/** Optional target value to show a progress bar beneath the value */
target?: number
/** Current progress (0100) when target is set */
progressPercent?: number
/** Show a subtle pulse/glow animation on the card */
pulse?: boolean
/** Apply a gradient to the value text */
valueGradient?: boolean
}
export default function StatCard({
title,
value,
change,
vsLastMonthLabel = 'vs last month',
icon: Icon,
iconColor = 'text-orange-700 dark:text-orange-300',
iconBg = 'bg-orange-50 dark:bg-orange-950/30',
target,
progressPercent,
pulse,
valueGradient,
}: StatCardProps) {
const isPositive = change !== undefined && change >= 0
const isNegative = change !== undefined && change < 0
return (
<div className={`card relative overflow-hidden p-5 ${pulse ? 'pulse-glow' : ''}`}>
<span className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-blue-700 to-orange-600 opacity-80" aria-hidden="true" />
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{title}</p>
<p
className={[
'mt-3 text-[34px] font-black leading-none tracking-[-0.035em]',
valueGradient
? 'bg-gradient-to-br from-blue-950 via-blue-600 to-orange-500 bg-clip-text text-transparent dark:from-slate-100 dark:via-blue-200 dark:to-orange-300'
: 'bg-gradient-to-br from-blue-950 to-slate-700 bg-clip-text text-transparent dark:from-slate-100 dark:to-slate-300',
].join(' ')}
>
{value}
</p>
{target !== undefined && progressPercent !== undefined && (
<div className="mt-2 flex items-center gap-2">
<div className="progress-bar flex-1">
<div
className="progress-fill orange"
style={{ width: `${Math.min(progressPercent, 100)}%` }}
/>
</div>
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">
{Math.round(progressPercent)}%
</span>
</div>
)}
{change !== undefined && (
<div className="mt-2 flex items-center gap-1">
<span
className={[
'text-xs font-medium',
isPositive ? 'text-emerald-600 dark:text-emerald-300' : isNegative ? 'text-red-600 dark:text-red-300' : 'text-slate-500 dark:text-slate-400',
].join(' ')}
>
{isPositive ? '+' : ''}{change.toFixed(1)}%
</span>
<span className="text-xs text-slate-500 dark:text-slate-500">{vsLastMonthLabel}</span>
</div>
)}
</div>
<div className={`flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[var(--rdg-radius-sm)] ${iconBg}`}>
<Icon className={`h-6 w-6 ${iconColor}`} />
</div>
</div>
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import {
appendInvitedMember,
applyActivationStats,
applyMemberActivation,
applyMemberRoleUpdate,
incrementInvitedStats,
removeTeamMemberState,
type TeamMember,
type TeamStats,
} from './useTeam'
function member(overrides: Partial<TeamMember>): TeamMember {
return {
id: 'member-1',
firstName: 'Amina',
lastName: 'Bennis',
email: 'amina@example.com',
phone: null,
role: 'AGENT',
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
lastActiveAt: null,
invitationStatus: 'accepted',
...overrides,
}
}
const stats: TeamStats = { total: 3, active: 2, pending: 1, inactive: 0 }
describe('useTeam state helpers', () => {
it('appends invited employees and increments the pending counters immutably', () => {
const existing = [member({ id: 'owner', role: 'OWNER' })]
const invited = member({ id: 'invite-1', role: 'MANAGER', invitationStatus: 'pending', isActive: false })
expect(appendInvitedMember(existing, invited)).toEqual([...existing, invited])
expect(existing).toHaveLength(1)
expect(incrementInvitedStats(stats)).toEqual({ total: 4, active: 2, pending: 2, inactive: 0 })
})
it('updates roles and activation flags only on the targeted member', () => {
const members = [member({ id: 'a' }), member({ id: 'b', role: 'MANAGER', isActive: false })]
expect(applyMemberRoleUpdate(members, 'a', 'MANAGER')).toEqual([
{ ...members[0], role: 'MANAGER' },
members[1],
])
expect(applyMemberActivation(members, 'b', true)).toEqual([
members[0],
{ ...members[1], isActive: true },
])
})
it('moves counters between active and inactive for deactivate/reactivate actions', () => {
expect(applyActivationStats({ total: 5, active: 4, pending: 0, inactive: 1 }, 'deactivate')).toEqual({
total: 5,
active: 3,
pending: 0,
inactive: 2,
})
expect(applyActivationStats({ total: 5, active: 3, pending: 0, inactive: 2 }, 'reactivate')).toEqual({
total: 5,
active: 4,
pending: 0,
inactive: 1,
})
})
it('removes active, pending, and inactive accepted members with the correct counter impact', () => {
const members = [
member({ id: 'active', isActive: true, invitationStatus: 'accepted' }),
member({ id: 'pending', isActive: false, invitationStatus: 'pending' }),
member({ id: 'inactive', isActive: false, invitationStatus: 'accepted' }),
]
expect(removeTeamMemberState(members, { total: 3, active: 1, pending: 1, inactive: 1 }, 'active').stats).toEqual({
total: 2,
active: 0,
pending: 1,
inactive: 1,
})
expect(removeTeamMemberState(members, { total: 3, active: 1, pending: 1, inactive: 1 }, 'pending').stats).toEqual({
total: 2,
active: 1,
pending: 0,
inactive: 1,
})
expect(removeTeamMemberState(members, { total: 3, active: 1, pending: 1, inactive: 1 }, 'inactive').stats).toEqual({
total: 2,
active: 1,
pending: 1,
inactive: 0,
})
})
it('leaves counters untouched when asked to remove an unknown member', () => {
const members = [member({ id: 'only' })]
const result = removeTeamMemberState(members, { total: 1, active: 1, pending: 0, inactive: 0 }, 'missing')
expect(result.members).toEqual(members)
expect(result.stats).toEqual({ total: 1, active: 1, pending: 0, inactive: 0 })
})
})
+160
View File
@@ -0,0 +1,160 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { apiFetch } from '@/lib/api'
export type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
export type InvitationStatus = 'accepted' | 'pending' | 'revoked'
export interface TeamMember {
id: string
firstName: string
lastName: string
email: string
phone: string | null
role: EmployeeRole
isActive: boolean
createdAt: string
updatedAt: string
lastActiveAt: string | null
invitationStatus: InvitationStatus
}
export interface TeamStats {
total: number
active: number
pending: number
inactive: number
}
export interface InvitePayload {
firstName: string
lastName: string
email: string
role: 'MANAGER' | 'AGENT'
}
export function appendInvitedMember(members: TeamMember[], employee: TeamMember): TeamMember[] {
return [...members, employee]
}
export function incrementInvitedStats(stats: TeamStats): TeamStats {
return { ...stats, total: stats.total + 1, pending: stats.pending + 1 }
}
export function applyMemberRoleUpdate(members: TeamMember[], memberId: string, role: 'MANAGER' | 'AGENT'): TeamMember[] {
return members.map((member) => (member.id === memberId ? { ...member, role } : member))
}
export function applyMemberActivation(members: TeamMember[], memberId: string, isActive: boolean): TeamMember[] {
return members.map((member) => (member.id === memberId ? { ...member, isActive } : member))
}
export function applyActivationStats(stats: TeamStats, direction: 'deactivate' | 'reactivate'): TeamStats {
return direction === 'deactivate'
? { ...stats, active: stats.active - 1, inactive: stats.inactive + 1 }
: { ...stats, active: stats.active + 1, inactive: stats.inactive - 1 }
}
export function removeTeamMemberState(members: TeamMember[], stats: TeamStats, memberId: string): { members: TeamMember[]; stats: TeamStats } {
const target = members.find((member) => member.id === memberId)
return {
members: members.filter((member) => member.id !== memberId),
stats: {
...stats,
total: stats.total - (target ? 1 : 0),
active: target?.isActive ? stats.active - 1 : stats.active,
pending: target?.invitationStatus === 'pending' ? stats.pending - 1 : stats.pending,
inactive: !target?.isActive && target?.invitationStatus === 'accepted'
? stats.inactive - 1
: stats.inactive,
},
}
}
export function useTeam() {
const [members, setMembers] = useState<TeamMember[]>([])
const [stats, setStats] = useState<TeamStats>({ total: 0, active: 0, pending: 0, inactive: 0 })
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const refetch = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [membersData, statsData] = await Promise.all([
apiFetch<TeamMember[]>('/team'),
apiFetch<TeamStats>('/team/stats'),
])
setMembers(membersData)
setStats(statsData)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { refetch() }, [refetch])
const invite = useCallback(async (payload: InvitePayload) => {
const result = await apiFetch<{ employee: TeamMember; invitationId: string }>(
'/team/invite',
{ method: 'POST', body: JSON.stringify(payload) }
)
setMembers((prev) => appendInvitedMember(prev, result.employee))
setStats(incrementInvitedStats)
return result
}, [])
const updateRole = useCallback(async (memberId: string, role: 'MANAGER' | 'AGENT') => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/role`, {
method: 'PATCH',
body: JSON.stringify({ role }),
})
setMembers((prev) => applyMemberRoleUpdate(prev, memberId, updated.role as 'MANAGER' | 'AGENT'))
return updated
}, [])
const deactivate = useCallback(async (memberId: string) => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/deactivate`, {
method: 'POST',
})
setMembers((prev) => applyMemberActivation(prev, memberId, false))
setStats((prev) => applyActivationStats(prev, 'deactivate'))
return updated
}, [])
const reactivate = useCallback(async (memberId: string) => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/reactivate`, {
method: 'POST',
})
setMembers((prev) => applyMemberActivation(prev, memberId, true))
setStats((prev) => applyActivationStats(prev, 'reactivate'))
return updated
}, [])
const remove = useCallback(async (memberId: string) => {
await apiFetch<{ success: boolean }>(`/team/${memberId}`, {
method: 'DELETE',
})
const nextState = removeTeamMemberState(members, stats, memberId)
setMembers(nextState.members)
setStats(nextState.stats)
}, [members, stats])
return {
members,
stats,
loading,
error,
refetch,
invite,
updateRole,
deactivate,
reactivate,
remove,
}
}
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
function installBrowser(token?: string) {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
localStorage: {
getItem: vi.fn(() => token ?? null),
},
},
})
}
afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'fetch')
vi.resetModules()
})
describe('dashboard apiFetch', () => {
it('adds JSON headers and sends cookies for browser requests', async () => {
installBrowser()
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ data: { ok: true } }),
}))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { apiFetch } = await import('./api')
await expect(apiFetch('/team')).resolves.toEqual({ ok: true })
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/team$/), expect.objectContaining({
credentials: 'include',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
}))
})
it('does not force JSON content type for FormData payloads', async () => {
installBrowser()
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { uploaded: true } }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { apiFetch } = await import('./api')
await apiFetch('/uploads', { method: 'POST', body: new FormData() })
expect((fetchMock as any).mock.calls[0]?.[1].headers).not.toHaveProperty('Content-Type')
expect((fetchMock as any).mock.calls[0]?.[1]).toEqual(expect.objectContaining({ credentials: 'include' }))
})
it('surfaces API error details on failed responses', async () => {
installBrowser()
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
value: vi.fn(async () => ({
ok: false,
status: 403,
json: async () => ({ message: 'Forbidden', error: 'FORBIDDEN' }),
})),
})
const { apiFetch } = await import('./api')
await expect(apiFetch('/team')).rejects.toMatchObject({ message: 'Forbidden', code: 'FORBIDDEN', statusCode: 403 })
})
it('uses explicit server token and disables caching for server requests', async () => {
Reflect.deleteProperty(globalThis, 'window')
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { ok: true } }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { apiFetchServer } = await import('./api')
await apiFetchServer('/fleet', 'server-token')
expect(fetchMock).toHaveBeenCalledWith('http://localhost:4000/api/v1/fleet', expect.objectContaining({
cache: 'no-store',
headers: expect.objectContaining({ Authorization: 'Bearer server-token' }),
}))
})
})
+68
View File
@@ -0,0 +1,68 @@
export const API_BASE =
typeof window === 'undefined'
? (process.env.API_INTERNAL_URL || 'http://localhost:4000/api/v1')
: (process.env.NEXT_PUBLIC_API_URL || '/dashboard/api/v1')
export const EMPLOYEE_PROFILE_KEY = 'employee_profile'
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
const headers: Record<string, string> = {
...(options?.headers as Record<string, string> ?? {}),
}
if (!isFormData) {
headers['Content-Type'] = 'application/json'
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: 'include',
})
let json: any
try {
json = await res.json()
} catch {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
if (!res.ok) {
const err = new Error(json?.message ?? json?.error ?? `Request failed with status ${res.status}`) as any
err.code = json?.error
err.statusCode = res.status
throw err
}
return (json?.data ?? json) as T
}
export async function apiFetchServer<T>(path: string, token: string, options?: RequestInit): Promise<T> {
const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
'Authorization': `Bearer ${token}`,
...(options?.headers as Record<string, string> ?? {}),
},
cache: 'no-store',
})
let json: any
try {
json = await res.json()
} catch {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
if (!res.ok) {
const err = new Error(json?.message ?? `Request failed with status ${res.status}`) as any
err.statusCode = res.status
throw err
}
return (json?.data ?? json) as T
}
+34
View File
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it } from 'vitest'
import { resolveBrowserAppUrl } from './appUrls'
function installWindow(hostname: string, protocol = 'https:') {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { location: { hostname, protocol } },
})
}
afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
})
describe('dashboard resolveBrowserAppUrl', () => {
it('returns the fallback untouched on the server', () => {
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard')).toBe('http://localhost:3001/dashboard')
})
it('keeps local browser fallbacks local and removes trailing slash', () => {
installWindow('localhost')
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard/')).toBe('http://localhost:3001/dashboard')
})
it('rewrites fallback origin to the current production host', () => {
installWindow('tenant.example.com', 'https:')
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard')).toBe('https://tenant.example.com/dashboard')
})
it('falls back safely when the configured value is not a URL', () => {
installWindow('tenant.example.com')
expect(resolveBrowserAppUrl('/dashboard/')).toBe('/dashboard/')
})
})
+15
View File
@@ -0,0 +1,15 @@
export function resolveBrowserAppUrl(fallback: string): string {
if (typeof window === 'undefined') return fallback
const h = window.location.hostname
if (h === 'localhost' || h === '127.0.0.1') return fallback.replace(/\/$/, '')
try {
const target = new URL(fallback)
target.protocol = window.location.protocol
target.hostname = h
target.port = ''
return target.toString().replace(/\/$/, '')
} catch {
return fallback
}
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { toDashboardAppPath, toPublicDashboardPath } from './dashboardPaths'
describe('dashboard path normalization', () => {
it('collapses empty and root values to the app root', () => {
expect(toDashboardAppPath()).toBe('/')
expect(toDashboardAppPath('')).toBe('/')
expect(toDashboardAppPath('/')).toBe('/')
})
it('strips repeated dashboard base prefixes', () => {
expect(toDashboardAppPath('/dashboard')).toBe('/')
expect(toDashboardAppPath('/dashboard/fleet')).toBe('/fleet')
expect(toDashboardAppPath('/dashboard/dashboard/reservations')).toBe('/reservations')
})
it('adds the public deployment prefix exactly once', () => {
expect(toPublicDashboardPath('/')).toBe('/dashboard')
expect(toPublicDashboardPath('/dashboard/fleet')).toBe('/dashboard/fleet')
expect(toPublicDashboardPath('customers')).toBe('/dashboard/customers')
})
})
+19
View File
@@ -0,0 +1,19 @@
const DASHBOARD_BASE_PATH = '/dashboard'
export function toDashboardAppPath(path?: string | null): string {
const value = (path ?? '').trim()
if (!value || value === '/') return '/'
let normalized = value.startsWith('/') ? value : `/${value}`
while (normalized === DASHBOARD_BASE_PATH || normalized.startsWith(`${DASHBOARD_BASE_PATH}/`)) {
normalized = normalized.slice(DASHBOARD_BASE_PATH.length) || '/'
}
return normalized
}
export function toPublicDashboardPath(path?: string | null): string {
const appPath = toDashboardAppPath(path)
return appPath === '/' ? DASHBOARD_BASE_PATH : `${DASHBOARD_BASE_PATH}${appPath}`
}
+96
View File
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
getScopedPreferenceCookieName,
getScopedPreferenceKey,
readCurrentUserScopedPreference,
readScopedPreference,
writeScopedPreference,
} from './preferences'
function installBrowser(cookie = '') {
const store = new Map<string, string>()
let cookieValue = cookie
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
localStorage: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
},
},
})
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
get cookie() {
return cookieValue
},
set cookie(value: string) {
const [pair] = value.split(';')
const [name, val] = pair.split('=')
const existing = cookieValue
.split(';')
.map((chunk) => chunk.trim())
.filter(Boolean)
.filter((chunk) => !chunk.startsWith(`${name}=`))
existing.push(`${name}=${val}`)
cookieValue = existing.join('; ')
},
},
})
Object.defineProperty(globalThis, 'atob', {
configurable: true,
value: (value: string) => Buffer.from(value, 'base64').toString('binary'),
})
return { store, get cookie() { return cookieValue } }
}
beforeEach(() => {
installBrowser()
})
afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'document')
Reflect.deleteProperty(globalThis, 'atob')
})
describe('dashboard scoped preferences', () => {
it('uses base keys when no employee token is available', () => {
expect(getScopedPreferenceKey('theme')).toBe('theme')
expect(getScopedPreferenceCookieName('theme')).toBe('theme')
})
it('prefers scoped cookie/current user values before local storage', () => {
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'document')
const browser = installBrowser('theme=dark')
browser.store.set('theme', 'local-dark')
expect(readCurrentUserScopedPreference('theme')).toBe('dark')
expect(readScopedPreference('theme')).toBe('dark')
})
it('falls back through shared cookie, scoped local storage, base key, and legacy keys', () => {
const browser = installBrowser('theme=dark')
expect(readScopedPreference('theme', ['legacy-theme'])).toBe('dark')
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'document')
const second = installBrowser()
second.store.set('theme', 'base')
second.store.set('legacy-theme', 'legacy')
expect(readScopedPreference('theme', ['legacy-theme'])).toBe('base')
})
it('writes shared and legacy preference values without auth-token scoping', () => {
const browser = installBrowser()
writeScopedPreference('language', 'fr', ['old-language'])
expect(browser.store.get('language')).toBe('fr')
expect(browser.store.get('old-language')).toBe('fr')
expect(browser.cookie).toContain('language=fr')
})
})
+94
View File
@@ -0,0 +1,94 @@
export const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
export const SHARED_LANGUAGE_KEY = 'rentaldrivego-language'
export const SHARED_THEME_KEY = 'rentaldrivego-theme'
function readEmployeeToken() {
return null
}
function decodeEmployeeId(token: string | null) {
if (!token) return null
try {
const encoded = token.split('.')[1] ?? ''
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const payload = JSON.parse(atob(padded)) as { sub?: string }
return typeof payload.sub === 'string' && payload.sub ? payload.sub : null
} catch {
return null
}
}
export function getScopedPreferenceKey(baseKey: string) {
const employeeId = decodeEmployeeId(readEmployeeToken())
return employeeId ? `${baseKey}:${employeeId}` : baseKey
}
export function getScopedPreferenceCookieName(baseKey: string) {
const employeeId = decodeEmployeeId(readEmployeeToken())
return employeeId ? `${baseKey}--${employeeId}` : baseKey
}
function readCookie(name: string) {
if (typeof document === 'undefined') return null
return document.cookie
.split(';')
.map((chunk) => chunk.trim())
.find((chunk) => chunk.startsWith(`${name}=`))
?.slice(name.length + 1) ?? null
}
function writeCookie(name: string, value: string) {
if (typeof document === 'undefined') return
document.cookie = `${name}=${value}; path=/; max-age=31536000; samesite=lax`
}
export function readCurrentUserScopedPreference(baseKey: string) {
if (typeof window === 'undefined') return null
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
if (scopedCookie) return scopedCookie
return window.localStorage.getItem(getScopedPreferenceKey(baseKey))
}
export function readScopedPreference(baseKey: string, legacyKeys: string[] = []) {
if (typeof window === 'undefined') return null
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
if (scopedCookie) return scopedCookie
const sharedCookie = readCookie(baseKey)
if (sharedCookie) return sharedCookie
const scopedKey = getScopedPreferenceKey(baseKey)
const candidates = [scopedKey, baseKey, ...legacyKeys]
for (const key of candidates) {
const value = window.localStorage.getItem(key)
if (value) return value
}
return null
}
export function writeScopedPreference(baseKey: string, value: string, legacyKeys: string[] = []) {
if (typeof window === 'undefined') return
const scopedKey = getScopedPreferenceKey(baseKey)
const scopedCookie = getScopedPreferenceCookieName(baseKey)
writeCookie(baseKey, value)
if (scopedCookie !== baseKey) {
writeCookie(scopedCookie, value)
}
window.localStorage.setItem(scopedKey, value)
window.localStorage.setItem(baseKey, value)
for (const key of legacyKeys) {
window.localStorage.setItem(key, value)
}
}
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
function installWindow(hostname: string, protocol = 'https:') {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { location: { hostname, protocol } },
})
}
async function loadUrls(env: Record<string, string | undefined> = {}) {
vi.resetModules()
for (const key of ['NEXT_PUBLIC_MARKETPLACE_URL', 'NEXT_PUBLIC_ADMIN_URL']) {
if (env[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = env[key]
}
}
return import('./urls')
}
afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
delete process.env.NEXT_PUBLIC_MARKETPLACE_URL
delete process.env.NEXT_PUBLIC_ADMIN_URL
vi.resetModules()
})
describe('dashboard cross-app URLs', () => {
it('uses default marketplace and admin bases on the server', async () => {
const { marketplaceUrl, adminUrl } = await loadUrls()
expect(marketplaceUrl).toBe('http://localhost:3000')
expect(adminUrl).toBe('http://localhost:3000/admin')
})
it('strips trailing slashes from configured absolute URLs', async () => {
const { marketplaceUrl, adminUrl } = await loadUrls({
NEXT_PUBLIC_MARKETPLACE_URL: 'https://market.example.com/',
NEXT_PUBLIC_ADMIN_URL: 'https://ops.example.com/admin/',
})
expect(marketplaceUrl).toBe('https://market.example.com')
expect(adminUrl).toBe('https://ops.example.com/admin')
})
it('rewrites local browser fallbacks onto the current production host', async () => {
installWindow('tenant.rentaldrivego.com', 'https:')
const { marketplaceUrl, adminUrl } = await loadUrls()
expect(marketplaceUrl).toBe('https://tenant.rentaldrivego.com')
expect(adminUrl).toBe('https://tenant.rentaldrivego.com/admin')
})
it('preserves non-URL values after normalizing their trailing slash', async () => {
const { marketplaceUrl, adminUrl } = await loadUrls({
NEXT_PUBLIC_MARKETPLACE_URL: '/marketplace/',
NEXT_PUBLIC_ADMIN_URL: '/admin/',
})
expect(marketplaceUrl).toBe('/marketplace')
expect(adminUrl).toBe('/admin')
})
})
+18
View File
@@ -0,0 +1,18 @@
import { resolveBrowserAppUrl } from '@/lib/appUrls'
function toAppBase(url: string): string {
try {
const u = new URL(url)
return (u.origin + u.pathname).replace(/\/$/, '')
} catch {
return url.replace(/\/$/, '')
}
}
export const marketplaceUrl = toAppBase(
resolveBrowserAppUrl(process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'),
)
export const adminUrl = toAppBase(
resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3000/admin'),
)
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const nextServer = vi.hoisted(() => {
const redirect = vi.fn((url: URL) => ({ kind: 'redirect', url: url.toString() }))
const next = vi.fn(() => ({ kind: 'next' }))
const MockNextResponse = vi.fn((body?: string, init?: { status?: number }) => ({
kind: 'response',
body,
status: init?.status,
})) as any
MockNextResponse.redirect = redirect
MockNextResponse.next = next
return { redirect, next, MockNextResponse }
})
vi.mock('next/server', () => ({
NextResponse: nextServer.MockNextResponse,
}))
function cloneableUrl(input: string): URL {
const url = new URL(input)
;(url as URL & { clone: () => URL }).clone = () => cloneableUrl(url.toString())
return url
}
function request(input: string, options: { token?: string; headers?: Record<string, string> } = {}) {
const headers = new Map(Object.entries(options.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]))
return {
nextUrl: cloneableUrl(input),
cookies: {
get: vi.fn((name: string) => (name === 'employee_session' && options.token ? { value: options.token } : undefined)),
},
headers: {
get: vi.fn((name: string) => headers.get(name.toLowerCase()) ?? null),
has: vi.fn((name: string) => headers.has(name.toLowerCase())),
},
}
}
async function loadMiddleware(marketplaceUrl = 'https://market.example.com') {
vi.resetModules()
process.env.NEXT_PUBLIC_MARKETPLACE_URL = marketplaceUrl
return import('./middleware')
}
beforeEach(() => {
nextServer.redirect.mockClear()
nextServer.next.mockClear()
})
afterEach(() => {
delete process.env.NEXT_PUBLIC_MARKETPLACE_URL
vi.resetModules()
})
describe('dashboard middleware', () => {
it('redirects duplicate dashboard prefixes to the clean dashboard URL', async () => {
const { default: middleware } = await loadMiddleware()
const response = middleware(request('https://workspace.example.com/dashboard/dashboard?x=1') as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.example.com/dashboard?x=1' })
})
it('rejects middleware subrequest headers at the app layer', async () => {
const { default: middleware } = await loadMiddleware()
const response = middleware(request('https://workspace.example.com/dashboard', {
headers: { 'x-middleware-subrequest': 'middleware:middleware' },
}) as never)
expect(response).toEqual({ kind: 'response', body: 'Unsupported internal request header', status: 400 })
})
it('redirects unauthenticated protected internal-host requests to dashboard sign-in on the resolved origin', async () => {
const { default: middleware } = await loadMiddleware('https://rentaldrivego.example')
const response = middleware(request('http://dashboard:3001/dashboard/team') as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example:3001/dashboard/sign-in?redirect=%2Fdashboard%2Fteam' })
})
it('uses trusted forwarded host/proto when building the dashboard sign-in redirect', async () => {
const { default: middleware } = await loadMiddleware('https://market.example.com')
const response = middleware(request('http://dashboard:3001/dashboard/billing', {
headers: {
'x-forwarded-host': 'workspace.customer.example',
'x-forwarded-proto': 'https',
},
}) as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.customer.example:3001/dashboard/sign-in?redirect=%2Fdashboard%2Fbilling' })
})
it('ignores internal forwarded hosts when building the dashboard sign-in redirect', async () => {
const { default: middleware } = await loadMiddleware('https://market.example.com')
const response = middleware(request('http://dashboard:3001/dashboard/fleet', {
headers: {
'x-forwarded-host': 'api:4000',
'x-forwarded-proto': 'https',
},
}) as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com:3001/dashboard/sign-in?redirect=%2Fdashboard%2Ffleet' })
})
it('redirects signed-in users away from the sign-in page', async () => {
const { default: middleware } = await loadMiddleware()
const response = middleware(request('https://workspace.example.com/dashboard/sign-in?redirect=/dashboard/fleet', { token: 'employee-token' }) as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.example.com/dashboard' })
})
it('allows public dashboard auth pages without a token', async () => {
const { default: middleware } = await loadMiddleware()
const response = middleware(request('https://workspace.example.com/dashboard/forgot-password') as never)
expect(response).toEqual({ kind: 'next' })
expect(nextServer.next).toHaveBeenCalledTimes(1)
})
})
+97
View File
@@ -0,0 +1,97 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const MARKETPLACE_URL = process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
const HOMEPAGE_URL = process.env.NEXT_PUBLIC_HOMEPAGE_URL ?? 'http://localhost:3000'
const DASHBOARD_BASE_PATH = '/dashboard'
function toDashboardAppPath(pathname: string): string {
let normalized = pathname || '/'
while (normalized === DASHBOARD_BASE_PATH || normalized.startsWith(`${DASHBOARD_BASE_PATH}/`)) {
normalized = normalized.slice(DASHBOARD_BASE_PATH.length) || '/'
}
return normalized
}
function toPublicDashboardPath(pathname: string): string {
const appPath = toDashboardAppPath(pathname)
return appPath === '/' ? DASHBOARD_BASE_PATH : `${DASHBOARD_BASE_PATH}${appPath}`
}
function deduplicatePublicDashboardPath(pathname: string): string | null {
if (!pathname.startsWith(`${DASHBOARD_BASE_PATH}${DASHBOARD_BASE_PATH}`)) return null
let normalized = pathname
while (normalized.startsWith(`${DASHBOARD_BASE_PATH}${DASHBOARD_BASE_PATH}`)) {
normalized = normalized.slice(DASHBOARD_BASE_PATH.length)
}
return normalized || DASHBOARD_BASE_PATH
}
function resolveProxyUrl(req: NextRequest, pathname: string): URL {
const forwardedHost = req.headers.get('x-forwarded-host')
const forwardedProto = req.headers.get('x-forwarded-proto')
const marketplaceOrigin = new URL(MARKETPLACE_URL)
const url = new URL(pathname, req.nextUrl.origin)
if (forwardedHost && !isInternalHost(forwardedHost)) {
url.host = forwardedHost
url.protocol = (forwardedProto ?? 'http') + ':'
} else if (!isInternalHost(req.nextUrl.host)) {
url.host = req.nextUrl.host
url.protocol = req.nextUrl.protocol
} else {
url.host = marketplaceOrigin.host
url.protocol = marketplaceOrigin.protocol
}
return url
}
function isInternalHost(host: string | null): boolean {
if (!host) return false
const hostname = host.split(':')[0]?.toLowerCase()
return ['host.docker.internal', 'dashboard', 'admin', 'api', 'marketplace'].includes(hostname)
}
function isProtectedRoute(req: NextRequest) {
const pathname = toDashboardAppPath(req.nextUrl.pathname)
if (['/sign-up', '/onboarding'].some((p) => pathname === p || pathname.startsWith(p + '/'))) return false
return pathname === '/' || (pathname.startsWith('/') && !pathname.startsWith('/_next'))
}
function localJwtMiddleware(req: NextRequest): NextResponse {
const token = req.cookies.get('employee_session')?.value
if (!isProtectedRoute(req)) return NextResponse.next()
if (!token) {
const signInUrl = new URL('/sign-in', HOMEPAGE_URL)
signInUrl.searchParams.set('redirect', req.nextUrl.pathname)
return NextResponse.redirect(signInUrl)
}
return NextResponse.next()
}
export default function middleware(req: NextRequest) {
if (req.headers.has('x-middleware-subrequest')) {
return new NextResponse('Unsupported internal request header', { status: 400 })
}
const dedupedPath = deduplicatePublicDashboardPath(req.nextUrl.pathname)
if (dedupedPath) {
const redirectUrl = req.nextUrl.clone()
redirectUrl.pathname = dedupedPath
return NextResponse.redirect(redirectUrl)
}
return localJwtMiddleware(req)
}
export const config = {
matcher: [
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
'/(api|trpc)(.*)',
],
}
+27
View File
@@ -0,0 +1,27 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
describe('dashboard next config', () => {
it('allows generated static media assets to be loaded across local app origins', async () => {
const nextConfig = require('../next.config.js')
const headers = await nextConfig.headers()
expect(headers).toEqual(
expect.arrayContaining([
{
source: '/_next/static/media/:path*',
headers: expect.arrayContaining([
{
key: 'Access-Control-Allow-Origin',
value: '*',
},
]),
},
]),
)
})
})
+162
View File
@@ -0,0 +1,162 @@
/* RentalDriveGo Phase 16 design tokens.
* Kept deliberately identical across the homepage, operator dashboard, and
* platform admin so the product behaves like one system rather than three
* unrelated Tailwind experiments.
*/
:root {
--rdg-blue-50: #eff6ff;
--rdg-blue-100: #dbeafe;
--rdg-blue-200: #bfdbfe;
--rdg-blue-500: #2874e8;
--rdg-blue-600: #1b5dd8;
--rdg-blue-700: #174bb5;
--rdg-blue-800: #183d88;
--rdg-orange-100: #ffedd5;
--rdg-orange-400: #fb923c;
--rdg-orange-600: #ea580c;
--rdg-orange-700: #c2410c;
--rdg-navy-950: #081426;
--rdg-navy-900: #0d1b2e;
--rdg-navy-850: #11233b;
--rdg-navy-800: #162b46;
--rdg-gray-25: #fbfdff;
--rdg-gray-50: #f7f9fc;
--rdg-gray-100: #eef2f7;
--rdg-gray-200: #dbe3ed;
--rdg-gray-300: #c6d0dc;
--rdg-gray-500: #64748b;
--rdg-gray-600: #475569;
--rdg-gray-700: #334155;
--rdg-gray-800: #1e293b;
--rdg-gray-900: #0f172a;
--rdg-green-100: #dcfce7;
--rdg-green-700: #15803d;
--rdg-amber-100: #fef3c7;
--rdg-amber-800: #92400e;
--rdg-red-100: #fee2e2;
--rdg-red-700: #b91c1c;
--rdg-space-1: 4px;
--rdg-space-2: 8px;
--rdg-space-3: 12px;
--rdg-space-4: 16px;
--rdg-space-5: 20px;
--rdg-space-6: 24px;
--rdg-space-8: 32px;
--rdg-space-10: 40px;
--rdg-space-12: 48px;
--rdg-space-16: 64px;
--rdg-space-20: 80px;
--rdg-space-24: 96px;
--rdg-radius-sm: 10px;
--rdg-radius-md: 16px;
--rdg-radius-lg: 24px;
--rdg-radius-xl: 32px;
--rdg-radius-pill: 999px;
--rdg-touch-min: 44px;
--rdg-header-size: 76px;
--rdg-sidebar-size: 272px;
--rdg-container-max: 1440px;
--rdg-content-max: 1280px;
--rdg-duration-fast: 120ms;
--rdg-duration-standard: 160ms;
--rdg-duration-overlay: 220ms;
--rdg-easing: cubic-bezier(0.2, 0, 0, 1);
--rdg-font-latin: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--rdg-font-arabic: "Noto Sans Arabic", Tahoma, Arial, sans-serif;
--rdg-surface-page: var(--rdg-gray-25);
--rdg-surface-primary: #ffffff;
--rdg-surface-elevated: #ffffff;
--rdg-surface-muted: #f2f6fb;
--rdg-surface-strong: #e8f0fa;
--rdg-text-primary: #102035;
--rdg-text-secondary: #52647a;
--rdg-text-subdued: var(--rdg-gray-500);
--rdg-text-inverse: #f8fafc;
--rdg-border: #d8e2ee;
--rdg-border-strong: #bdcad9;
--rdg-action-primary: var(--rdg-blue-700);
--rdg-action-primary-hover: var(--rdg-blue-800);
--rdg-action-conversion: var(--rdg-orange-700);
--rdg-action-conversion-hover: #9a3412;
--rdg-focus: #f97316;
--rdg-soft-blue: #eef5ff;
--rdg-soft-orange: #fff7ed;
--rdg-overlay: rgba(8, 20, 38, 0.62);
--rdg-shadow-color: rgba(8, 20, 38, 0.12);
--rdg-shadow-subtle: 0 1px 2px var(--rdg-shadow-color);
--rdg-shadow-card: 0 20px 60px var(--rdg-shadow-color);
--rdg-shadow-overlay: 0 28px 80px var(--rdg-shadow-color);
/* Compatibility aliases retained for existing pages. */
--primary: var(--rdg-action-primary);
--primary-hover: var(--rdg-action-primary-hover);
--accent: var(--rdg-action-conversion);
--bg-elevated: var(--rdg-surface-elevated);
--border-accent: var(--rdg-border);
--glass-bg: color-mix(in srgb, var(--rdg-surface-elevated) 90%, transparent);
--shadow-card: var(--rdg-shadow-card);
--shadow-glow: 0 0 20px color-mix(in srgb, var(--rdg-action-primary) 16%, transparent);
}
html.dark,
html[data-theme='dark'] {
--rdg-surface-page: var(--rdg-navy-950);
--rdg-surface-primary: var(--rdg-navy-900);
--rdg-surface-elevated: var(--rdg-navy-850);
--rdg-surface-muted: #0b1a2d;
--rdg-surface-strong: #172d48;
--rdg-text-primary: #eef5ff;
--rdg-text-secondary: #adbed2;
--rdg-text-subdued: var(--rdg-gray-300);
--rdg-text-inverse: var(--rdg-navy-950);
--rdg-border: #263c56;
--rdg-border-strong: #3a536e;
--rdg-action-primary: #76a9ff;
--rdg-action-primary-hover: #9bc2ff;
--rdg-action-conversion: var(--rdg-orange-400);
--rdg-action-conversion-hover: #fdba74;
--rdg-focus: var(--rdg-orange-400);
--rdg-soft-blue: #10284a;
--rdg-soft-orange: #3a2015;
--rdg-overlay: rgba(2, 8, 18, 0.76);
--rdg-shadow-color: rgba(0, 0, 0, 0.36);
color-scheme: dark;
}
html.light,
html[data-theme='light'] {
color-scheme: light;
}
html[lang='ar'] body {
font-family: var(--rdg-font-arabic);
line-height: 1.8;
}
::selection {
background: var(--rdg-blue-200);
color: var(--rdg-gray-900);
}
html.dark ::selection,
html[data-theme='dark'] ::selection {
background: var(--rdg-blue-800);
color: var(--rdg-gray-25);
}
:focus-visible {
outline: 3px solid var(--rdg-focus);
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}