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
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:
@@ -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 n’est 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 l’encaissement du dépôt de garantie remboursable.',
|
||||
invalidAmount: 'Saisissez un montant valide dans la limite du solde restant.',
|
||||
paymentActionDisabled: 'Aucun solde dû',
|
||||
paymentsRecorded: (count: number) => `${count} paiement(s)`,
|
||||
none: '—',
|
||||
loading: 'Chargement de la facturation client…',
|
||||
noRows: 'Aucune facture client trouvée.',
|
||||
failed: 'Échec du chargement de la facturation client.',
|
||||
openContract: 'Ouvrir le contrat',
|
||||
openBooking: 'Ouvrir la réservation',
|
||||
charge: 'Paiement',
|
||||
depositType: 'Dépôt',
|
||||
unknownInvoice: 'Facture brouillon',
|
||||
paymentStatusLabels: {
|
||||
UNPAID: 'Non payé',
|
||||
PAID: 'Payé',
|
||||
PARTIAL: 'Partiel',
|
||||
PENDING: 'En attente',
|
||||
REFUNDED: 'Remboursé',
|
||||
FAILED: 'Échoué',
|
||||
OVERDUE: 'En retard',
|
||||
} as Record<string, string>,
|
||||
paymentEventLabels: {
|
||||
PENDING: 'En attente',
|
||||
SUCCEEDED: 'Réussi',
|
||||
FAILED: 'Échoué',
|
||||
REFUNDED: 'Remboursé',
|
||||
PARTIALLY_REFUNDED: 'Partiellement remboursé',
|
||||
} as Record<string, string>,
|
||||
paymentProviderLabels: {
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<string, string>,
|
||||
paymentMethodLabels: {
|
||||
CASH: 'Espèces',
|
||||
CHECK: 'Chèque',
|
||||
BANK_TRANSFER: 'Virement bancaire',
|
||||
CARD: 'Carte',
|
||||
PAYPAL: 'PayPal',
|
||||
STRIPE: 'Carte',
|
||||
CREDIT_CARD: 'Carte',
|
||||
DEBIT_CARD: 'Carte',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
ar: {
|
||||
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 n’importe 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 l’entreprise 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
/** 0–100 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 d’assurance, 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 jusqu’au retour du véhicule.',
|
||||
activeLockMessage: 'La location est en cours. Les détails de la réservation sont en lecture seule jusqu’au retour du véhicule.',
|
||||
returnLockMessage: 'Le véhicule est de retour. Seule la section de retour et l’inspection de retour peuvent encore être modifiées avant la clôture.',
|
||||
closedLockMessage: 'La réservation est clôturée. Aucune autre modification n’est autorisée.',
|
||||
closedMeta: 'Clôturée',
|
||||
checkoutSummary: 'Statut du retour',
|
||||
checkoutPending: 'En attente des modifications de retour',
|
||||
checkoutReady: 'Section retour ouverte',
|
||||
checkoutClosed: 'Réservation clôturée',
|
||||
checkInReadOnly: 'L’inspection de départ est modifiable après confirmation et avant le retour.',
|
||||
checkOutReadOnly: 'L’inspection de retour devient modifiable une fois le véhicule retourné.',
|
||||
inspectionClosed: 'Cette réservation est clôturée. Les modifications d’inspection sont désactivées.',
|
||||
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 d’identité',
|
||||
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 l’espace, 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 n’est 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 l’affichage 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 d’assurance',
|
||||
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 d’assurance 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 n’est 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 d’abonnement.',
|
||||
trial: 'Essai gratuit',
|
||||
remaining: 'restants. Abonnez-vous avant la fin pour garder l’accè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: 'S’abonner',
|
||||
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: 'S’abonner 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 n’est configuré. Contactez le support pour activer AmanPay ou PayPal.',
|
||||
providerUnavailable: 'Ce prestataire de paiement n’est 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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 l’historique 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 l’automatique.',
|
||||
},
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 d’utilisation', 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">
|
||||
© {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 d’agence',
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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 l’espace de travail. Les propriétaires ont un accès complet à l’ensemble 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)
|
||||
})
|
||||
})
|
||||
@@ -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 (0–100) 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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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' }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'),
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)(.*)',
|
||||
],
|
||||
}
|
||||
@@ -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: '*',
|
||||
},
|
||||
]),
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user