archetecture security fix
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="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 p-6 ${isRtl ? 'rtl' : 'ltr'}`}>
|
||||
{/* Header */}
|
||||
<div className="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="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>
|
||||
<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="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,805 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Plus, Edit, Eye, Search, Upload, X, ChevronDown, DollarSign } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { 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
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<Vehicle['status'], string> = {
|
||||
AVAILABLE: 'badge-green',
|
||||
RESERVED: 'badge-blue',
|
||||
READY: 'badge-purple',
|
||||
RENTED: 'badge-indigo',
|
||||
RETURNED: 'badge-amber',
|
||||
NEEDS_CLEANING: 'badge-amber',
|
||||
MAINTENANCE: 'badge-amber',
|
||||
DAMAGE_REVIEW: 'badge-red',
|
||||
OUT_OF_SERVICE: 'badge-red',
|
||||
}
|
||||
|
||||
const ALL_STATUSES: Vehicle['status'][] = [
|
||||
'AVAILABLE', 'RESERVED', 'READY', 'RENTED',
|
||||
'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE',
|
||||
]
|
||||
|
||||
const STATUS_DOT: Record<Vehicle['status'], string> = {
|
||||
AVAILABLE: 'bg-green-500',
|
||||
RESERVED: 'bg-blue-500',
|
||||
READY: 'bg-teal-500',
|
||||
RENTED: 'bg-indigo-500',
|
||||
RETURNED: 'bg-amber-400',
|
||||
NEEDS_CLEANING: 'bg-amber-500',
|
||||
MAINTENANCE: 'bg-orange-500',
|
||||
DAMAGE_REVIEW: 'bg-red-400',
|
||||
OUT_OF_SERVICE: 'bg-red-600',
|
||||
}
|
||||
|
||||
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 }: {
|
||||
vehicleId: string
|
||||
status: Vehicle['status']
|
||||
onStatusChange: (id: string, status: Vehicle['status']) => void
|
||||
}) {
|
||||
const { dict } = useDashboardI18n()
|
||||
const f = dict.fleet
|
||||
const [open, setOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const openDropdown = () => {
|
||||
if (triggerRef.current) {
|
||||
const r = triggerRef.current.getBoundingClientRect()
|
||||
setPos({ top: r.bottom + 4, left: r.left })
|
||||
}
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const close = (e: MouseEvent) => {
|
||||
if (
|
||||
!triggerRef.current?.contains(e.target as Node) &&
|
||||
!panelRef.current?.contains(e.target as Node)
|
||||
) setOpen(false)
|
||||
}
|
||||
const closeOnScroll = () => setOpen(false)
|
||||
document.addEventListener('mousedown', close)
|
||||
window.addEventListener('scroll', closeOnScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', close)
|
||||
window.removeEventListener('scroll', closeOnScroll, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const select = async (next: Vehicle['status']) => {
|
||||
if (next === status) { setOpen(false); return }
|
||||
setOpen(false)
|
||||
setSaving(true)
|
||||
try {
|
||||
await apiFetch(`/vehicles/${vehicleId}/status`, { method: 'PATCH', body: JSON.stringify({ status: next }) })
|
||||
onStatusChange(vehicleId, next)
|
||||
} catch (err: any) {
|
||||
alert(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={openDropdown}
|
||||
disabled={saving}
|
||||
className={`inline-flex items-center gap-1 ${STATUS_BADGE[status]} cursor-pointer select-none`}
|
||||
>
|
||||
{saving ? '…' : f.statusLabels[status]}
|
||||
<ChevronDown className="w-3 h-3 opacity-60" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
className="fixed z-50 min-w-[160px] rounded-xl border border-slate-100 bg-white shadow-lg py-1"
|
||||
>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => select(s)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-start hover:bg-slate-50 transition-colors ${s === status ? 'font-semibold' : ''}`}
|
||||
>
|
||||
<span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${STATUS_DOT[s]}`} />
|
||||
{f.statusLabels[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 } = useDashboardI18n()
|
||||
const f = dict.fleet
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
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() }, [])
|
||||
|
||||
const togglePublished = async (id: string, current: boolean) => {
|
||||
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) {
|
||||
alert(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
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="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">
|
||||
<Plus className="w-4 h-4" /> {f.addVehicle}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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} />
|
||||
</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)}
|
||||
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',
|
||||
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={() => setShowAddModal(false)} onSaved={fetchVehicles} />
|
||||
{maintenanceModal && (
|
||||
<MaintenanceModal
|
||||
vehicleId={maintenanceModal.vehicleId}
|
||||
vehicleName={maintenanceModal.vehicleName}
|
||||
open={true}
|
||||
onClose={() => setMaintenanceModal(null)}
|
||||
onSaved={() => setMaintenanceModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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="flex min-h-screen bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] transition-colors dark:bg-[linear-gradient(180deg,#0f1f4a_0%,#112d6e_35%,#0d1f4f_100%)] print:block print:min-h-0 print:bg-white">
|
||||
<div className="print:hidden">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="ms-0 flex min-h-screen min-w-0 flex-1 flex-col lg:ms-64 print:ms-0 print:min-h-0 print:block">
|
||||
<div className="print:hidden">
|
||||
<TopBar />
|
||||
</div>
|
||||
<main className="flex-1 overflow-y-auto p-6 print:p-0 print:overflow-visible">
|
||||
<DashboardAccessGuard>{children}</DashboardAccessGuard>
|
||||
</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="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="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,310 @@
|
||||
'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 }
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
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="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
|
||||
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>
|
||||
|
||||
{/* 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,316 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle } 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'
|
||||
|
||||
interface DashboardData {
|
||||
kpis: {
|
||||
totalBookings: number
|
||||
activeVehicles: number
|
||||
monthlyRevenue: number
|
||||
totalCustomers: number
|
||||
bookingsChange: number
|
||||
vehiclesChange: number
|
||||
revenueChange: number
|
||||
customersChange: 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 }[]
|
||||
subscription: {
|
||||
status: string
|
||||
planName: string
|
||||
trialEndsAt: string | null
|
||||
}
|
||||
}
|
||||
|
||||
interface EmployeeProfile {
|
||||
role?: string
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
CONFIRMED: 'badge-blue',
|
||||
PENDING: 'badge-amber',
|
||||
ACTIVE: 'badge-green',
|
||||
COMPLETED: 'badge-gray',
|
||||
CANCELLED: 'badge-red',
|
||||
}
|
||||
|
||||
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-50 border-blue-200 text-blue-800',
|
||||
icon: <Clock className="w-4 h-4" />,
|
||||
message: trialEndsAt ? trialEndsMsg(localeDate) : trialActiveMsg,
|
||||
},
|
||||
PAST_DUE: {
|
||||
bg: 'bg-orange-50 border-orange-200 text-orange-800',
|
||||
icon: <AlertTriangle className="w-4 h-4" />,
|
||||
message: pastDueMsg,
|
||||
},
|
||||
SUSPENDED: {
|
||||
bg: 'bg-red-50 border-red-200 text-red-800',
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { dict, language } = useDashboardI18n()
|
||||
const d = dict.dashboardPage
|
||||
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
|
||||
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
|
||||
if (!cached) return
|
||||
const profile = JSON.parse(cached) as EmployeeProfile
|
||||
setEmployeeRole(profile.role ?? null)
|
||||
} 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">{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-6">
|
||||
{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-600"
|
||||
iconBg="bg-blue-50"
|
||||
/>
|
||||
<StatCard
|
||||
title={d.kpiActiveVehicles}
|
||||
value={kpis.activeVehicles.toLocaleString()}
|
||||
change={kpis.vehiclesChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={Car}
|
||||
iconColor="text-green-600"
|
||||
iconBg="bg-green-50"
|
||||
/>
|
||||
{canViewRevenue ? (
|
||||
<StatCard
|
||||
title={d.kpiMonthlyRevenue}
|
||||
value={formatCurrency(kpis.monthlyRevenue, 'MAD')}
|
||||
change={kpis.revenueChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={DollarSign}
|
||||
iconColor="text-orange-600"
|
||||
iconBg="bg-orange-50"
|
||||
/>
|
||||
) : null}
|
||||
<StatCard
|
||||
title={d.kpiTotalCustomers}
|
||||
value={kpis.totalCustomers.toLocaleString()}
|
||||
change={kpis.customersChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={Users}
|
||||
iconColor="text-purple-600"
|
||||
iconBg="bg-purple-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Booking Sources Chart */}
|
||||
<div className="card p-6 lg:col-span-2">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-4">{d.bookingSources}</h2>
|
||||
{data?.sourceBreakdown && data.sourceBreakdown.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={data.sourceBreakdown}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="source" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: '8px', border: '1px solid #e2e8f0', fontSize: '12px' }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
||||
<Bar dataKey="count" fill="#3b82f6" name={d.barBookings} radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="revenue" fill="#10b981" 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>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-4">{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 text-slate-600 capitalize">{item.source.toLowerCase()}</span>
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-semibold text-slate-900">{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="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-slate-900">{d.recentReservations}</h2>
|
||||
<Link href="/reservations" className="text-sm text-blue-600 hover:text-blue-700 font-medium">
|
||||
{d.viewAll}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100">
|
||||
<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-slate-100">
|
||||
{(data?.recentReservations ?? []).map((res) => (
|
||||
<tr key={res.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-6 py-3">
|
||||
<Link href={`/reservations/${res.id}`} className="text-sm font-medium text-blue-600 hover:text-blue-700">
|
||||
#{res.bookingRef}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-700">{res.customerName}</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-700">{res.vehicleName}</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-500">
|
||||
{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-sm font-semibold text-slate-900 text-right">
|
||||
{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="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,824 @@
|
||||
'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
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
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',
|
||||
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: 'صورة الرخصة',
|
||||
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="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>}
|
||||
|
||||
<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="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,114 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
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 }
|
||||
}
|
||||
|
||||
export default function ReservationsPage() {
|
||||
const { dict, language } = useDashboardI18n()
|
||||
const r = dict.reservations
|
||||
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
|
||||
|
||||
const [rows, setRows] = useState<ReservationRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<ReservationRow[]>('/reservations?pageSize=100')
|
||||
.then((result) => setRows(result ?? []))
|
||||
.catch((err) => setError(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'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="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>
|
||||
<Link href="/reservations/new" className="btn-primary whitespace-nowrap">
|
||||
{language === 'fr' ? 'Réserver une voiture' : language === 'ar' ? 'حجز سيارة' : 'Book car'}
|
||||
</Link>
|
||||
</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">{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>
|
||||
</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 p-6 ${isRtl ? 'rtl' : 'ltr'}`}>
|
||||
{/* Header */}
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
<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="max-w-5xl mx-auto py-8 px-4 sm:px-6">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<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
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Suspense } from 'react'
|
||||
import SignInPageClient from '@/app/sign-in/[[...sign-in]]/SignInPageClient'
|
||||
|
||||
export default async function SignInPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const params = await searchParams
|
||||
const embedded = params.embedded === '1'
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SignInPageClient embedded={embedded} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { API_BASE } from '@/lib/api'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
|
||||
export default function ForgotPasswordPageClient({ embedded = false }: { embedded?: boolean }) {
|
||||
const { language } = useDashboardI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
title: 'Forgot your password?',
|
||||
subtitle: "Enter your work email and we'll send you a reset link.",
|
||||
email: 'Email',
|
||||
submit: 'Send reset link',
|
||||
submitting: 'Sending…',
|
||||
backToLogin: 'Back to sign in',
|
||||
successTitle: 'Check your email',
|
||||
successBody: 'If that email is registered, a reset link has been sent. It expires in 60 minutes.',
|
||||
error: 'Something went wrong. Please try again.',
|
||||
},
|
||||
fr: {
|
||||
title: 'Mot de passe oublié ?',
|
||||
subtitle: "Entrez votre email professionnel et nous vous enverrons un lien de réinitialisation.",
|
||||
email: 'Email',
|
||||
submit: 'Envoyer le lien',
|
||||
submitting: 'Envoi…',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
successTitle: 'Vérifiez votre email',
|
||||
successBody: "Si cet email est enregistré, un lien de réinitialisation a été envoyé. Il expire dans 60 minutes.",
|
||||
error: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
},
|
||||
ar: {
|
||||
title: 'نسيت كلمة المرور؟',
|
||||
subtitle: 'أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.',
|
||||
email: 'البريد الإلكتروني',
|
||||
submit: 'إرسال رابط إعادة التعيين',
|
||||
submitting: 'جارٍ الإرسال…',
|
||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
||||
successTitle: 'تحقق من بريدك الإلكتروني',
|
||||
successBody: 'إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.',
|
||||
error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Try employee first, then admin — mirrors the sign-in page which handles both
|
||||
const [empRes, adminRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/auth/employee/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
fetch(`${API_BASE}/admin/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
])
|
||||
if (!empRes.ok && !adminRes.ok) throw new Error()
|
||||
setSent(true)
|
||||
} catch {
|
||||
setError(dict.error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell embedded={embedded}>
|
||||
<main className="flex flex-1 items-center justify-center px-4 py-12">
|
||||
<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_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
width={104}
|
||||
height={104}
|
||||
priority
|
||||
className="h-24 w-24 rounded-[1.75rem] border border-stone-200 bg-white p-1.5 shadow-sm transition-colors dark:border-blue-800 dark:bg-blue-950"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<h1 className="mt-4 text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">{dict.title}</h1>
|
||||
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm transition-colors dark:border-blue-900 dark:bg-blue-950">
|
||||
{sent ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/50">
|
||||
<svg className="h-7 w-7 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>
|
||||
<h2 className="text-xl font-bold text-stone-900 dark:text-stone-100">{dict.successTitle}</h2>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.successBody}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">{dict.email}</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="owner@company.com"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.submitting : dict.submit}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-t border-stone-200 pt-6 text-center text-sm text-stone-500 dark:border-blue-900 dark:text-stone-400">
|
||||
<Link href="/sign-in" className="font-semibold text-stone-900 underline decoration-stone-300 underline-offset-4 dark:text-stone-100 dark:decoration-stone-700">
|
||||
{dict.backToLogin}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import ForgotPasswordPageClient from './ForgotPasswordPageClient'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return <ForgotPasswordPageClient embedded={true} />
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html.light {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--accent: #f97316;
|
||||
--sidebar-bg: #0a1128;
|
||||
--sidebar-text: #94a3b8;
|
||||
--sidebar-active: #ffffff;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-blue-950 antialiased transition-colors dark:text-slate-100;
|
||||
background-image:
|
||||
linear-gradient(180deg, #ffffff 0%, #f5f8ff 28%, #eef4ff 58%, #ffffff 100%);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
background-image:
|
||||
linear-gradient(180deg, #0a1128 0%, #0d1b38 35%, #07101e 100%);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center gap-2 rounded-full bg-orange-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-orange-500 dark:hover:bg-orange-400;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center gap-2 rounded-full border border-stone-300 bg-white/85 px-5 py-2.5 text-sm font-semibold text-stone-700 transition hover:border-blue-900 hover:text-blue-900 dark:border-blue-800 dark:bg-blue-950/20 dark:text-blue-100 dark:hover:border-blue-400 dark:hover:text-white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center gap-2 rounded-full bg-red-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-red-700 disabled:opacity-50;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply 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-900 dark:bg-blue-950/50 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus:ring-orange-400;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-[1.75rem] border shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:shadow-[0_30px_80px_rgba(0,0,0,0.36)];
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark .card {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-color: rgb(11 25 55 / 0.72);
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
@apply inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300;
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
@apply inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-950/40 dark:text-blue-300;
|
||||
}
|
||||
|
||||
.badge-amber {
|
||||
@apply inline-flex items-center rounded-full bg-orange-100 px-2.5 py-0.5 text-xs font-medium text-orange-700 dark:bg-orange-950/40 dark:text-orange-300;
|
||||
}
|
||||
|
||||
.badge-red {
|
||||
@apply inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-950/40 dark:text-red-300;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
@apply inline-flex items-center rounded-full bg-stone-100 px-2.5 py-0.5 text-xs font-medium text-stone-600 dark:bg-blue-950/40 dark:text-slate-300;
|
||||
}
|
||||
|
||||
.badge-purple {
|
||||
@apply inline-flex items-center rounded-full bg-teal-100 px-2.5 py-0.5 text-xs font-medium text-teal-700 dark:bg-teal-950/40 dark:text-teal-300;
|
||||
}
|
||||
|
||||
.badge-indigo {
|
||||
@apply inline-flex items-center rounded-full bg-indigo-100 px-2.5 py-0.5 text-xs font-medium text-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-300;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 10mm 12mm;
|
||||
}
|
||||
* {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.dark .bg-slate-50 {
|
||||
background-color: rgb(7 16 46);
|
||||
}
|
||||
|
||||
.dark .bg-white {
|
||||
background-color: rgb(11 25 55);
|
||||
}
|
||||
|
||||
.dark .bg-white\/90 {
|
||||
background-color: rgb(11 25 55 / 0.9);
|
||||
}
|
||||
|
||||
.dark .border-slate-200,
|
||||
.dark .border-stone-200 {
|
||||
border-color: rgb(30 60 140 / 0.50);
|
||||
}
|
||||
|
||||
.dark .text-slate-900 {
|
||||
color: rgb(241 245 249);
|
||||
}
|
||||
|
||||
.dark .text-slate-700 {
|
||||
color: rgb(203 213 225);
|
||||
}
|
||||
|
||||
.dark .text-slate-600,
|
||||
.dark .text-stone-500 {
|
||||
color: rgb(148 163 184);
|
||||
}
|
||||
|
||||
.dark .text-slate-500,
|
||||
.dark .text-slate-400,
|
||||
.dark .text-stone-400 {
|
||||
color: rgb(100 116 139);
|
||||
}
|
||||
|
||||
.dark .hover\:bg-slate-100:hover {
|
||||
background-color: rgb(20 42 90);
|
||||
}
|
||||
|
||||
.dark .hover\:text-slate-900:hover {
|
||||
color: rgb(241 245 249);
|
||||
}
|
||||
}
|
||||
@@ -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,38 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import { DashboardI18nProvider } from '@/components/I18nProvider'
|
||||
import { SHARED_LANGUAGE_COOKIE } from '@/lib/preferences'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'RentalDriveGo Dashboard',
|
||||
description: 'Manage your rental car business',
|
||||
}
|
||||
|
||||
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">
|
||||
<DashboardI18nProvider initialLanguage={initialLanguage}>{children}</DashboardI18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Link from 'next/link'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
|
||||
export default function AcceptInvitePage() {
|
||||
return (
|
||||
<PublicShell>
|
||||
<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>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
@@ -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 PublicShell from '@/components/layout/PublicShell'
|
||||
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 (
|
||||
<PublicShell>
|
||||
<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>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ForgotPasswordPageClient from './forgot-password/ForgotPasswordPageClient'
|
||||
import ResetPasswordPageClient from './reset-password/ResetPasswordPageClient'
|
||||
import ForgotPasswordPage from './forgot-password/page'
|
||||
import ResetPasswordPage from './reset-password/page'
|
||||
import AcceptInvitePage from './onboarding/accept-invite/page'
|
||||
|
||||
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 findElement(node: unknown, predicate: (element: React.ReactElement) => boolean): React.ReactElement | null {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') return null
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findElement(child, predicate)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (!isValidElement<{ children?: React.ReactNode }>(node)) return null
|
||||
if (predicate(node)) return node
|
||||
return findElement(node.props.children, predicate)
|
||||
}
|
||||
|
||||
describe('dashboard public auth pages', () => {
|
||||
it('renders forgot-password in embedded mode for the public shell route', () => {
|
||||
const page = ForgotPasswordPage()
|
||||
|
||||
expect(isValidElement(page)).toBe(true)
|
||||
expect(page.type).toBe(ForgotPasswordPageClient)
|
||||
expect(page.props.embedded).toBe(true)
|
||||
})
|
||||
|
||||
it('renders reset-password in embedded mode for the public shell route', () => {
|
||||
const page = ResetPasswordPage()
|
||||
|
||||
expect(isValidElement(page)).toBe(true)
|
||||
expect(page.type).toBe(ResetPasswordPageClient)
|
||||
expect(page.props.embedded).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps accept-invite copy and sign-in handoff visible', () => {
|
||||
const page = AcceptInvitePage()
|
||||
const text = collectText(page).join(' ')
|
||||
const signInLink = findElement(page, (element) => element.props.href === '/sign-in')
|
||||
|
||||
expect(text).toContain('Invitation accepted')
|
||||
expect(text).toContain('Sign in to dashboard')
|
||||
expect(signInLink).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,214 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Suspense, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { API_BASE } from '@/lib/api'
|
||||
|
||||
export default function ResetPasswordPageClient({ embedded = false }: { embedded?: boolean }) {
|
||||
return (
|
||||
<Suspense>
|
||||
<ResetPasswordContent embedded={embedded} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function ResetPasswordContent({ embedded = false }: { embedded?: boolean }) {
|
||||
const { language } = useDashboardI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
title: 'Set new password',
|
||||
subtitle: 'Choose a strong password for your workspace account.',
|
||||
newPassword: 'New password',
|
||||
confirmPassword: 'Confirm password',
|
||||
submit: 'Reset password',
|
||||
submitting: 'Resetting…',
|
||||
backToLogin: 'Back to sign in',
|
||||
successTitle: 'Password updated',
|
||||
successBody: 'Your password has been reset. You can now sign in with your new password.',
|
||||
signIn: 'Sign in',
|
||||
errorMismatch: 'Passwords do not match.',
|
||||
errorShort: 'Password must be at least 8 characters.',
|
||||
errorInvalidToken: 'This reset link is invalid or has expired. Please request a new one.',
|
||||
errorGeneric: 'Something went wrong. Please try again.',
|
||||
noToken: 'No reset token found. Please request a new password reset.',
|
||||
requestNew: 'Request new reset link',
|
||||
},
|
||||
fr: {
|
||||
title: 'Nouveau mot de passe',
|
||||
subtitle: "Choisissez un mot de passe fort pour votre compte.",
|
||||
newPassword: 'Nouveau mot de passe',
|
||||
confirmPassword: 'Confirmer le mot de passe',
|
||||
submit: 'Réinitialiser',
|
||||
submitting: 'Traitement…',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
successTitle: 'Mot de passe mis à jour',
|
||||
successBody: 'Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter.',
|
||||
signIn: 'Se connecter',
|
||||
errorMismatch: 'Les mots de passe ne correspondent pas.',
|
||||
errorShort: 'Le mot de passe doit contenir au moins 8 caractères.',
|
||||
errorInvalidToken: 'Ce lien est invalide ou a expiré. Veuillez en demander un nouveau.',
|
||||
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
noToken: 'Aucun jeton de réinitialisation trouvé. Veuillez demander un nouveau lien.',
|
||||
requestNew: 'Demander un nouveau lien',
|
||||
},
|
||||
ar: {
|
||||
title: 'تعيين كلمة مرور جديدة',
|
||||
subtitle: 'اختر كلمة مرور قوية لحساب مساحة العمل.',
|
||||
newPassword: 'كلمة المرور الجديدة',
|
||||
confirmPassword: 'تأكيد كلمة المرور',
|
||||
submit: 'إعادة تعيين',
|
||||
submitting: 'جارٍ المعالجة…',
|
||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
||||
successTitle: 'تم تحديث كلمة المرور',
|
||||
successBody: 'تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.',
|
||||
signIn: 'تسجيل الدخول',
|
||||
errorMismatch: 'كلمتا المرور غير متطابقتين.',
|
||||
errorShort: 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل.',
|
||||
errorInvalidToken: 'رابط إعادة التعيين غير صالح أو منتهي الصلاحية.',
|
||||
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
noToken: 'لم يتم العثور على رمز إعادة التعيين. يرجى طلب رابط جديد.',
|
||||
requestNew: 'طلب رابط جديد',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (password.length < 8) { setError(dict.errorShort); return }
|
||||
if (password !== confirm) { setError(dict.errorMismatch); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/employee/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
if (json?.error === 'invalid_token') throw new Error(dict.errorInvalidToken)
|
||||
throw new Error(dict.errorGeneric)
|
||||
}
|
||||
setDone(true)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? dict.errorGeneric)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<ResetShell embedded={embedded} title={dict.title}>
|
||||
<p className="text-center text-sm text-stone-500 dark:text-stone-400">{dict.noToken}</p>
|
||||
<div className="mt-4 text-center">
|
||||
<Link href="/forgot-password" className="btn-primary inline-flex">{dict.requestNew}</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<ResetShell embedded={embedded} title={dict.title}>
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/50">
|
||||
<svg className="h-7 w-7 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>
|
||||
<h2 className="text-xl font-bold text-stone-900 dark:text-stone-100">{dict.successTitle}</h2>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.successBody}</p>
|
||||
<Link href="/sign-in" className="btn-primary inline-flex justify-center">{dict.signIn}</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResetShell embedded={embedded} title={dict.title} subtitle={dict.subtitle}>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">{dict.newPassword}</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">{dict.confirmPassword}</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.submitting : dict.submit}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 border-t border-stone-200 pt-6 text-center text-sm text-stone-500 dark:border-blue-900 dark:text-stone-400">
|
||||
<Link href="/sign-in" className="font-semibold text-stone-900 underline decoration-stone-300 underline-offset-4 dark:text-stone-100 dark:decoration-stone-700">
|
||||
{dict.backToLogin}
|
||||
</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
function ResetShell({
|
||||
embedded = false,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
embedded?: boolean
|
||||
title: string
|
||||
subtitle?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<PublicShell embedded={embedded}>
|
||||
<main className="flex min-h-[calc(100vh-80px)] items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-400">RentalDriveGo</span>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">{title}</h1>
|
||||
{subtitle && <p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{subtitle}</p>}
|
||||
</div>
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm transition-colors dark:border-blue-900 dark:bg-blue-950">
|
||||
{children}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import ResetPasswordPageClient from './ResetPasswordPageClient'
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return <ResetPasswordPageClient embedded={true} />
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { adminUrl, marketplaceUrl } from '@/lib/urls'
|
||||
import { API_BASE, EMPLOYEE_PROFILE_KEY } from '@/lib/api'
|
||||
import { toPublicDashboardPath } from '@/lib/dashboardPaths'
|
||||
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
|
||||
function notifyParent(message: Record<string, unknown>) {
|
||||
if (typeof window === 'undefined' || window.parent === window) return
|
||||
window.parent.postMessage(message, '*')
|
||||
}
|
||||
|
||||
export default function SignInPageClient({ embedded = false }: { embedded?: boolean }) {
|
||||
const { language, theme, setLanguage, setTheme } = useDashboardI18n()
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const requestedLanguage = searchParams.get('lang')
|
||||
const requestedTheme = searchParams.get('theme')
|
||||
const initializedFromQuery = useRef(false)
|
||||
const dict = {
|
||||
en: {
|
||||
brandName: 'Space Agency',
|
||||
title: 'Sign in',
|
||||
subtitle: 'Enter your credentials to access your account.',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
signIn: 'Sign in',
|
||||
signingIn: 'Signing in…',
|
||||
verify: 'Verify code',
|
||||
verifying: 'Verifying…',
|
||||
authCode: 'Authentication code',
|
||||
enterCode: 'Enter the 6-digit code from your authenticator app.',
|
||||
back: 'Back to credentials',
|
||||
forgotPassword: 'Forgot your password?',
|
||||
invalidCredentials: 'Invalid email or password.',
|
||||
passwordNotSet: 'No password set yet. Check your invitation email or use "Forgot your password?"',
|
||||
unexpectedError: 'Something went wrong. Please try again.',
|
||||
},
|
||||
fr: {
|
||||
brandName: 'Espace agence',
|
||||
title: 'Connexion',
|
||||
subtitle: 'Saisissez vos identifiants pour accéder à votre compte.',
|
||||
email: 'Email',
|
||||
password: 'Mot de passe',
|
||||
signIn: 'Connexion',
|
||||
signingIn: 'Connexion…',
|
||||
verify: 'Vérifier le code',
|
||||
verifying: 'Vérification…',
|
||||
authCode: "Code d'authentification",
|
||||
enterCode: "Entrez le code à 6 chiffres de votre application d'authentification.",
|
||||
back: 'Retour aux identifiants',
|
||||
forgotPassword: 'Mot de passe oublié ?',
|
||||
invalidCredentials: 'Adresse e-mail ou mot de passe invalide.',
|
||||
passwordNotSet: `Aucun mot de passe défini. Vérifiez votre e-mail d'invitation ou utilisez « Mot de passe oublié ? »`,
|
||||
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
},
|
||||
ar: {
|
||||
brandName: 'مساحة الوكالة',
|
||||
title: 'تسجيل الدخول',
|
||||
subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
|
||||
email: 'البريد الإلكتروني',
|
||||
password: 'كلمة المرور',
|
||||
signIn: 'تسجيل الدخول',
|
||||
signingIn: 'جارٍ تسجيل الدخول…',
|
||||
verify: 'تحقق من الرمز',
|
||||
verifying: 'جارٍ التحقق…',
|
||||
authCode: 'رمز المصادقة',
|
||||
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
|
||||
back: 'العودة إلى بيانات الدخول',
|
||||
forgotPassword: 'نسيت كلمة المرور؟',
|
||||
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
|
||||
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
|
||||
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
},
|
||||
}[language]
|
||||
|
||||
useEffect(() => {
|
||||
if (initializedFromQuery.current) return
|
||||
|
||||
if (
|
||||
(requestedLanguage === 'en' || requestedLanguage === 'fr' || requestedLanguage === 'ar') &&
|
||||
requestedLanguage !== language
|
||||
) {
|
||||
setLanguage(requestedLanguage)
|
||||
}
|
||||
|
||||
if (
|
||||
(requestedTheme === 'light' || requestedTheme === 'dark') &&
|
||||
requestedTheme !== theme
|
||||
) {
|
||||
setTheme(requestedTheme)
|
||||
}
|
||||
|
||||
initializedFromQuery.current = true
|
||||
}, [language, requestedLanguage, requestedTheme, setLanguage, setTheme, theme])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initializedFromQuery.current) return
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
let changed = false
|
||||
|
||||
if (params.get('lang') !== language) {
|
||||
params.set('lang', language)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (params.get('theme') !== theme) {
|
||||
params.set('theme', theme)
|
||||
changed = true
|
||||
}
|
||||
|
||||
// useSearchParams() can return empty params during hydration without a Suspense boundary.
|
||||
// Always preserve embedded=1 when the component was server-rendered as embedded so
|
||||
// the router.replace doesn't strip it and trigger an unintended redirect.
|
||||
if (embedded && params.get('embedded') !== '1') {
|
||||
params.set('embedded', '1')
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (!changed) return
|
||||
|
||||
const nextQuery = params.toString()
|
||||
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { scroll: false })
|
||||
}, [embedded, language, pathname, router, searchParams, theme])
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = window.location.pathname + (window.location.search || '')
|
||||
notifyParent({ type: 'rentaldrivego:embedded-path', path: currentPath })
|
||||
}, [pathname, searchParams])
|
||||
|
||||
return (
|
||||
<PublicShell embedded={embedded}>
|
||||
<main className="flex flex-1 items-center justify-center px-4 py-12">
|
||||
<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_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
width={104}
|
||||
height={104}
|
||||
priority
|
||||
className="h-24 w-24 rounded-[1.75rem] border border-stone-200/80 bg-white/85 p-1.5 shadow-sm transition-colors dark:border-blue-800 dark:bg-blue-950/80"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-5 text-xs font-bold uppercase tracking-[0.28em] text-orange-700 dark:text-orange-300">
|
||||
RentalDriveGo
|
||||
</p>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">
|
||||
{dict.brandName}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-stone-600 dark:text-stone-300">
|
||||
{dict.subtitle}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
{(['en', 'fr', 'ar'] as const).map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => setLanguage(lang)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wider transition-colors text-orange-700 dark:text-orange-300 ${
|
||||
language === lang
|
||||
? 'ring-1 ring-current opacity-100'
|
||||
: 'opacity-45 hover:opacity-80'
|
||||
}`}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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)]">
|
||||
<LocalSignInForm dict={dict} />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
|
||||
function LocalSignInForm({
|
||||
dict,
|
||||
}: {
|
||||
dict: {
|
||||
email: string
|
||||
password: string
|
||||
signIn: string
|
||||
signingIn: string
|
||||
verify: string
|
||||
verifying: string
|
||||
authCode: string
|
||||
enterCode: string
|
||||
back: string
|
||||
forgotPassword: string
|
||||
invalidCredentials: string
|
||||
passwordNotSet: string
|
||||
unexpectedError: string
|
||||
}
|
||||
}) {
|
||||
const searchParams = useSearchParams()
|
||||
const { setLanguage } = useDashboardI18n()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [step, setStep] = useState<'credentials' | 'totp'>('credentials')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const adminNext = searchParams.get('next') || '/dashboard'
|
||||
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
|
||||
|
||||
function redirectAdmin(token: string) {
|
||||
const hash = new URLSearchParams({ token, next: adminNext }).toString()
|
||||
window.location.href = `${adminUrl}/auth-redirect#${hash}`
|
||||
}
|
||||
|
||||
async function handleCredentials(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const empJson = await empRes.json()
|
||||
|
||||
if (empRes.ok && empJson?.data?.employee) {
|
||||
const targetPath = toPublicDashboardPath(employeeRedirect)
|
||||
if (empJson?.data?.employee) {
|
||||
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
|
||||
const prefLang = empJson.data.employee?.preferredLanguage
|
||||
if (prefLang === 'en' || prefLang === 'fr' || prefLang === 'ar') {
|
||||
setLanguage(prefLang)
|
||||
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
|
||||
notifyParent({ type: 'rentaldrivego:employee-login', path: targetPath })
|
||||
// Use a document navigation here so the authenticated dashboard bootstraps
|
||||
// from a fresh request after the token cookie and localStorage are set.
|
||||
window.location.replace(targetPath)
|
||||
return
|
||||
}
|
||||
|
||||
if (empJson?.error === 'password_not_set') {
|
||||
setError(dict.passwordNotSet)
|
||||
return
|
||||
}
|
||||
|
||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const adminJson = await adminRes.json()
|
||||
|
||||
if (adminRes.ok && adminJson?.data?.admin) {
|
||||
window.location.href = `${adminUrl}/dashboard`
|
||||
return
|
||||
}
|
||||
|
||||
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
|
||||
setStep('totp')
|
||||
return
|
||||
}
|
||||
|
||||
setError(dict.invalidCredentials)
|
||||
} catch {
|
||||
setError(dict.unexpectedError)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotp(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const normalizedCode = totpCode.trim().toUpperCase()
|
||||
const secondFactor = /^\d{6}$/.test(normalizedCode)
|
||||
? { totpCode: normalizedCode }
|
||||
: { recoveryCode: normalizedCode }
|
||||
|
||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password, ...secondFactor }),
|
||||
})
|
||||
const adminJson = await adminRes.json()
|
||||
|
||||
if (adminRes.ok && adminJson?.data?.admin) {
|
||||
window.location.href = `${adminUrl}/dashboard`
|
||||
return
|
||||
}
|
||||
|
||||
setError(dict.invalidCredentials)
|
||||
} catch {
|
||||
setError(dict.unexpectedError)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{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}
|
||||
|
||||
{step === 'credentials' ? (
|
||||
<form onSubmit={handleCredentials} className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.email}</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="owner@company.com"
|
||||
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}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
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>
|
||||
</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-400 dark:text-white dark:hover:bg-orange-300"
|
||||
>
|
||||
{loading ? dict.signingIn : dict.signIn}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/forgot-password" className="text-sm text-stone-500 underline decoration-stone-300 underline-offset-4 hover:text-stone-900 dark:text-stone-400 dark:decoration-stone-600 dark:hover:text-stone-100">
|
||||
{dict.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleTotp} className="space-y-5">
|
||||
<div className="rounded-[1.5rem] border border-stone-200/80 bg-stone-50/90 px-4 py-4 text-center text-sm text-stone-600 dark:border-blue-800 dark:bg-blue-950/50 dark:text-stone-300">
|
||||
{dict.enterCode}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.authCode}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="text"
|
||||
pattern="[0-9A-Za-z-]{6,14}"
|
||||
maxLength={14}
|
||||
required
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/[^0-9A-Za-z-]/g, '').toUpperCase())}
|
||||
placeholder="000000 or XXXX-XXXX-XXXX"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-center text-xl tracking-[0.45em] 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>
|
||||
|
||||
<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-400 dark:text-white dark:hover:bg-orange-300"
|
||||
>
|
||||
{loading ? dict.verifying : dict.verify}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep('credentials')
|
||||
setTotpCode('')
|
||||
setError(null)
|
||||
}}
|
||||
className="w-full text-sm text-stone-500 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100"
|
||||
>
|
||||
{dict.back}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,936 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { getCurrencyLabel } from '@rentaldrivego/types'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
import { BilingualField, BilingualInput, emptyBilingual } from '@/components/ui/BilingualInput'
|
||||
|
||||
type SignupForm = {
|
||||
firstName: BilingualField
|
||||
lastName: BilingualField
|
||||
email: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
preferredLanguage: 'en' | 'fr' | 'ar' | ''
|
||||
companyName: BilingualField
|
||||
legalName: string
|
||||
legalForm: string
|
||||
registrationNumber: string
|
||||
iceNumber: string
|
||||
taxId: string
|
||||
operatingLicenseNumber: string
|
||||
operatingLicenseIssuedAt: string
|
||||
operatingLicenseIssuedBy: string
|
||||
streetAddress: BilingualField
|
||||
city: BilingualField
|
||||
country: BilingualField
|
||||
zipCode: string
|
||||
companyPhone: string
|
||||
companyEmail: string
|
||||
fax: string
|
||||
yearsActive: string
|
||||
representativeName: string
|
||||
representativeTitle: string
|
||||
responsibleName: string
|
||||
responsibleRole: string
|
||||
responsibleIdentityNumber: string
|
||||
responsibleQualification: string
|
||||
responsiblePhone: string
|
||||
responsibleEmail: string
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'
|
||||
billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
currency: 'MAD'
|
||||
paymentProvider: 'AMANPAY' | 'PAYPAL'
|
||||
}
|
||||
|
||||
type CompletedSignup = {
|
||||
companyName: string
|
||||
email: string
|
||||
emailWarning?: string | null
|
||||
}
|
||||
|
||||
type SignupResponse = {
|
||||
companyId: string
|
||||
companyName: string
|
||||
slug: string
|
||||
trialEndsAt: string | null
|
||||
nextStep: string
|
||||
emailDelivery?: {
|
||||
attempted: boolean
|
||||
success: boolean
|
||||
error: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
const LEGAL_FORM_OPTIONS = ['SARL', 'SARL AU', 'SA', 'SAS', 'AUTO_ENTREPRENEUR', 'EI', 'OTHER'] as const
|
||||
const YEARS_ACTIVE_OPTIONS = ['LESS_THAN_1', 'ONE_TO_FIVE', 'MORE_THAN_5'] as const
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
|
||||
export default function SignUpPage() {
|
||||
const { language, setLanguage } = useDashboardI18n()
|
||||
const searchParams = useSearchParams()
|
||||
const initialPlan = normalizePlan(searchParams.get('plan'))
|
||||
const initialBillingPeriod = normalizeBillingPeriod(searchParams.get('billing'))
|
||||
const initialCurrency = normalizeCurrency(searchParams.get('currency'))
|
||||
const [step, setStep] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [completedSignup, setCompletedSignup] = useState<CompletedSignup | null>(null)
|
||||
const [form, setForm] = useState<SignupForm>({
|
||||
firstName: emptyBilingual(),
|
||||
lastName: emptyBilingual(),
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
preferredLanguage: '',
|
||||
companyName: emptyBilingual(),
|
||||
legalName: '',
|
||||
legalForm: '',
|
||||
registrationNumber: '',
|
||||
iceNumber: '',
|
||||
taxId: '',
|
||||
operatingLicenseNumber: '',
|
||||
operatingLicenseIssuedAt: '',
|
||||
operatingLicenseIssuedBy: '',
|
||||
streetAddress: emptyBilingual(),
|
||||
city: emptyBilingual(),
|
||||
country: emptyBilingual(),
|
||||
zipCode: '',
|
||||
companyPhone: '',
|
||||
companyEmail: '',
|
||||
fax: '',
|
||||
yearsActive: '',
|
||||
representativeName: '',
|
||||
representativeTitle: '',
|
||||
responsibleName: '',
|
||||
responsibleRole: '',
|
||||
responsibleIdentityNumber: '',
|
||||
responsibleQualification: '',
|
||||
responsiblePhone: '',
|
||||
responsibleEmail: '',
|
||||
plan: initialPlan,
|
||||
billingPeriod: initialBillingPeriod,
|
||||
currency: initialCurrency,
|
||||
paymentProvider: 'AMANPAY',
|
||||
})
|
||||
|
||||
const isArabic = language === 'ar'
|
||||
const dict = {
|
||||
en: {
|
||||
brand: 'RentalDriveGo',
|
||||
pageTitle: 'Launch your rental workspace',
|
||||
pageSubtitle: 'Create the user account, complete the company information, and launch your workspace.',
|
||||
steps: ['Account User', 'Company info', 'Plan', 'Verify'],
|
||||
ownerTitle: 'Account User',
|
||||
ownerBody: 'Create the primary user account that will manage this company workspace.',
|
||||
partnerTitle: 'Company info',
|
||||
partnerBody: 'Complete the company information required to create the workspace.',
|
||||
planTitle: 'Choose your plan',
|
||||
planBody: 'Your workspace starts immediately on a 90-day free trial in your preferred billing currency.',
|
||||
reviewTitle: 'Review and launch',
|
||||
reviewBody: 'We will create the company workspace immediately and start the 90-day trial with the plan you selected.',
|
||||
firstName: 'Manager/Owner first name',
|
||||
lastName: 'Manager/Owner last name',
|
||||
ownerEmail: 'Manager/Owner email',
|
||||
emailReuseHint: 'You can reuse the same email for the owner, company, and responsible person.',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordHint: 'Use at least 8 characters for your company owner account.',
|
||||
companyName: 'Commercial name',
|
||||
legalName: 'Legal company name',
|
||||
legalForm: 'Legal form',
|
||||
registrationNumber: 'Commercial Registry (RC)',
|
||||
iceNumber: 'ICE number',
|
||||
taxId: 'Fiscal ID (IF)',
|
||||
operatingLicenseNumber: 'Operating license number',
|
||||
operatingLicenseIssuedAt: 'License issue date',
|
||||
operatingLicenseIssuedBy: 'Issuing authority',
|
||||
streetAddress: 'Street Address',
|
||||
city: 'City',
|
||||
country: 'Country',
|
||||
zipCode: 'Zip code',
|
||||
companyPhone: 'Phone',
|
||||
companyEmail: 'Company contact email',
|
||||
fax: 'Fax (optional)',
|
||||
yearsActive: 'Years active',
|
||||
representativeName: 'Legal representative name',
|
||||
representativeTitle: 'Legal representative title',
|
||||
responsibleName: 'Responsible person name',
|
||||
responsibleRole: 'Responsible person role',
|
||||
responsibleIdentityNumber: 'Responsible person ID number',
|
||||
responsibleQualification: 'Qualification / diploma / experience',
|
||||
responsiblePhone: 'Responsible person phone',
|
||||
responsibleEmail: 'Responsible person email',
|
||||
identitySection: 'Legal identity',
|
||||
contactSection: 'Company contact',
|
||||
representativeSection: 'Legal representative',
|
||||
responsibleSection: 'Responsible person',
|
||||
continue: 'Continue',
|
||||
back: 'Back',
|
||||
working: 'Working…',
|
||||
createWorkspace: 'Create workspace',
|
||||
workspaceReady: 'Workspace ready',
|
||||
workspaceReadyBody: 'is now active and the owner email',
|
||||
enterDashboard: 'Enter dashboard',
|
||||
signInAnotherDevice: 'Sign in on another device',
|
||||
reviewWorkspace: 'Workspace',
|
||||
reviewPlan: 'Plan',
|
||||
reviewOwnerEmail: 'Owner email',
|
||||
reviewCompanyEmail: 'Company email',
|
||||
reviewPhone: 'Phone',
|
||||
reviewLegalName: 'Legal name',
|
||||
reviewRegistration: 'RC / ICE / IF',
|
||||
invalidPassword: 'Choose a password with at least 8 characters.',
|
||||
passwordMismatch: 'Passwords do not match.',
|
||||
missingRequired: 'Fill in all required fields.',
|
||||
missingLanguage: 'Please select your preferred language.',
|
||||
preferredLanguageLabel: 'Preferred language',
|
||||
languageOptions: { en: 'English', fr: 'Français', ar: 'العربية' } as Record<string, string>,
|
||||
companyFallback: 'Your company',
|
||||
couldNotCreate: 'Could not create workspace',
|
||||
legalFormOptions: {
|
||||
'': 'Select legal form',
|
||||
SARL: 'SARL',
|
||||
'SARL AU': 'SARL AU',
|
||||
SA: 'SA',
|
||||
SAS: 'SAS',
|
||||
AUTO_ENTREPRENEUR: 'Auto-entrepreneur',
|
||||
EI: 'Sole proprietorship',
|
||||
OTHER: 'Other',
|
||||
} as Record<string, string>,
|
||||
yearsActiveOptions: {
|
||||
'': 'Select years active',
|
||||
LESS_THAN_1: 'Less than 1 year',
|
||||
ONE_TO_FIVE: '1 to 5 years',
|
||||
MORE_THAN_5: 'More than 5 years',
|
||||
} as Record<string, string>,
|
||||
planDescriptions: {
|
||||
STARTER: 'Core fleet, offers, and bookings.',
|
||||
GROWTH: 'Featured marketplace placement and more room to scale.',
|
||||
PRO: 'Full white-label and premium controls.',
|
||||
} as Record<SignupForm['plan'], string>,
|
||||
billingPeriodOptions: {
|
||||
MONTHLY: 'Monthly',
|
||||
ANNUAL: 'Annual',
|
||||
} as Record<SignupForm['billingPeriod'], string>,
|
||||
paymentProviderOptions: {
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<SignupForm['paymentProvider'], string>,
|
||||
},
|
||||
fr: {
|
||||
brand: 'RentalDriveGo',
|
||||
pageTitle: 'Lancez votre espace de location',
|
||||
pageSubtitle: 'Créez le compte utilisateur, complétez les informations de l’entreprise et lancez votre espace.',
|
||||
steps: ['Compte utilisateur', 'Infos entreprise', 'Plan', 'Vérification'],
|
||||
ownerTitle: 'Compte utilisateur',
|
||||
ownerBody: 'Créez le compte utilisateur principal qui gérera cet espace entreprise.',
|
||||
partnerTitle: 'Infos entreprise',
|
||||
partnerBody: 'Complétez les informations de l’entreprise requises pour créer l’espace.',
|
||||
planTitle: 'Choisissez votre formule',
|
||||
planBody: 'Votre espace démarre immédiatement avec un essai gratuit de 90 jours dans la devise choisie.',
|
||||
reviewTitle: 'Vérification et lancement',
|
||||
reviewBody: 'Nous allons créer immédiatement l’espace entreprise et démarrer l’essai de 90 jours avec la formule choisie.',
|
||||
firstName: 'Prénom du gérant/propriétaire',
|
||||
lastName: 'Nom du gérant/propriétaire',
|
||||
ownerEmail: 'E-mail du gérant/propriétaire',
|
||||
emailReuseHint: 'Vous pouvez utiliser le même e-mail pour le propriétaire, l’entreprise et le responsable.',
|
||||
password: 'Mot de passe',
|
||||
confirmPassword: 'Confirmer le mot de passe',
|
||||
passwordHint: 'Utilisez au moins 8 caractères pour le compte propriétaire.',
|
||||
companyName: 'Nom commercial',
|
||||
legalName: 'Raison sociale',
|
||||
legalForm: 'Forme juridique',
|
||||
registrationNumber: 'Registre du commerce (RC)',
|
||||
iceNumber: 'Numéro ICE',
|
||||
taxId: 'Identifiant fiscal (IF)',
|
||||
operatingLicenseNumber: 'Numéro d’agrément',
|
||||
operatingLicenseIssuedAt: 'Date de délivrance',
|
||||
operatingLicenseIssuedBy: 'Autorité délivrante',
|
||||
streetAddress: 'Adresse',
|
||||
city: 'Ville',
|
||||
country: 'Pays',
|
||||
zipCode: 'Code postal',
|
||||
companyPhone: 'Téléphone',
|
||||
companyEmail: 'E-mail de contact de l’entreprise',
|
||||
fax: 'Fax (optionnel)',
|
||||
yearsActive: 'Années d’activité',
|
||||
representativeName: 'Nom et prénom du représentant',
|
||||
representativeTitle: 'Fonction du représentant',
|
||||
responsibleName: 'Nom et prénom du responsable',
|
||||
responsibleRole: 'Qualité du responsable',
|
||||
responsibleIdentityNumber: 'N° de pièce d’identité',
|
||||
responsibleQualification: 'Qualification / diplôme / expérience',
|
||||
responsiblePhone: 'Téléphone du responsable',
|
||||
responsibleEmail: 'E-mail du responsable',
|
||||
identitySection: 'Identité juridique',
|
||||
contactSection: 'Coordonnées de l’entreprise',
|
||||
representativeSection: 'Représentant légal',
|
||||
responsibleSection: 'Responsable désigné',
|
||||
continue: 'Continuer',
|
||||
back: 'Retour',
|
||||
working: 'Traitement…',
|
||||
createWorkspace: 'Créer l’espace',
|
||||
workspaceReady: 'Espace prêt',
|
||||
workspaceReadyBody: 'est maintenant actif et l’e-mail propriétaire',
|
||||
enterDashboard: 'Accéder au tableau de bord',
|
||||
signInAnotherDevice: 'Se connecter sur un autre appareil',
|
||||
reviewWorkspace: 'Espace',
|
||||
reviewPlan: 'Formule',
|
||||
reviewOwnerEmail: 'E-mail propriétaire',
|
||||
reviewCompanyEmail: 'E-mail entreprise',
|
||||
reviewPhone: 'Téléphone',
|
||||
reviewLegalName: 'Raison sociale',
|
||||
reviewRegistration: 'RC / ICE / IF',
|
||||
invalidPassword: 'Choisissez un mot de passe d’au moins 8 caractères.',
|
||||
passwordMismatch: 'Les mots de passe ne correspondent pas.',
|
||||
missingRequired: 'Renseignez tous les champs obligatoires.',
|
||||
missingLanguage: 'Veuillez sélectionner votre langue préférée.',
|
||||
preferredLanguageLabel: 'Langue préférée',
|
||||
languageOptions: { en: 'English', fr: 'Français', ar: 'العربية' } as Record<string, string>,
|
||||
companyFallback: 'Votre entreprise',
|
||||
couldNotCreate: 'Impossible de créer l’espace',
|
||||
legalFormOptions: {
|
||||
'': 'Sélectionner la forme juridique',
|
||||
SARL: 'SARL',
|
||||
'SARL AU': 'SARL AU',
|
||||
SA: 'SA',
|
||||
SAS: 'SAS',
|
||||
AUTO_ENTREPRENEUR: 'Auto-entrepreneur',
|
||||
EI: 'Entreprise individuelle',
|
||||
OTHER: 'Autre',
|
||||
} as Record<string, string>,
|
||||
yearsActiveOptions: {
|
||||
'': 'Sélectionner les années d’activité',
|
||||
LESS_THAN_1: 'Moins de 1 an',
|
||||
ONE_TO_FIVE: '1 an à 5 ans',
|
||||
MORE_THAN_5: 'Plus de 5 ans',
|
||||
} as Record<string, string>,
|
||||
planDescriptions: {
|
||||
STARTER: 'Flotte, offres et réservations essentielles.',
|
||||
GROWTH: 'Mise en avant marketplace et plus de marge pour évoluer.',
|
||||
PRO: 'White-label complet et contrôles premium.',
|
||||
} as Record<SignupForm['plan'], string>,
|
||||
billingPeriodOptions: {
|
||||
MONTHLY: 'Mensuel',
|
||||
ANNUAL: 'Annuel',
|
||||
} as Record<SignupForm['billingPeriod'], string>,
|
||||
paymentProviderOptions: {
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<SignupForm['paymentProvider'], string>,
|
||||
},
|
||||
ar: {
|
||||
brand: 'RentalDriveGo',
|
||||
pageTitle: 'أطلق مساحة التأجير الخاصة بك',
|
||||
pageSubtitle: 'أنشئ حساب المستخدم، وأكمل معلومات الشركة، ثم أطلق مساحة العمل.',
|
||||
steps: ['حساب المستخدم', 'معلومات الشركة', 'الخطة', 'المراجعة'],
|
||||
ownerTitle: 'حساب المستخدم',
|
||||
ownerBody: 'أنشئ حساب المستخدم الرئيسي الذي سيدير مساحة عمل الشركة.',
|
||||
partnerTitle: 'معلومات الشركة',
|
||||
partnerBody: 'أكمل معلومات الشركة المطلوبة لإنشاء مساحة العمل.',
|
||||
planTitle: 'اختر خطتك',
|
||||
planBody: 'تبدأ المساحة فوراً بفترة تجريبية مجانية لمدة 14 يوماً بالعملة التي تفضلها.',
|
||||
reviewTitle: 'راجع وأطلق',
|
||||
reviewBody: 'سننشئ مساحة الشركة فوراً ونبدأ الفترة التجريبية لمدة 14 يوماً بالخطة التي اخترتها.',
|
||||
firstName: 'الاسم الأول للمدير/المالك',
|
||||
lastName: 'اسم العائلة للمدير/المالك',
|
||||
ownerEmail: 'بريد المدير/المالك الإلكتروني',
|
||||
emailReuseHint: 'يمكنك استخدام نفس البريد الإلكتروني للمالك والشركة والمسؤول.',
|
||||
password: 'كلمة المرور',
|
||||
confirmPassword: 'تأكيد كلمة المرور',
|
||||
passwordHint: 'استخدم 8 أحرف على الأقل لحساب مالك الشركة.',
|
||||
companyName: 'الاسم التجاري',
|
||||
legalName: 'الاسم القانوني للشركة',
|
||||
legalForm: 'الشكل القانوني',
|
||||
registrationNumber: 'السجل التجاري (RC)',
|
||||
iceNumber: 'رقم ICE',
|
||||
taxId: 'المعرف الضريبي (IF)',
|
||||
operatingLicenseNumber: 'رقم الترخيص',
|
||||
operatingLicenseIssuedAt: 'تاريخ إصدار الترخيص',
|
||||
operatingLicenseIssuedBy: 'الجهة المانحة',
|
||||
streetAddress: 'عنوان الشارع',
|
||||
city: 'المدينة',
|
||||
country: 'الدولة',
|
||||
zipCode: 'الرمز البريدي',
|
||||
companyPhone: 'الهاتف',
|
||||
companyEmail: 'بريد الشركة للتواصل',
|
||||
fax: 'الفاكس (اختياري)',
|
||||
yearsActive: 'سنوات النشاط',
|
||||
representativeName: 'اسم الممثل القانوني',
|
||||
representativeTitle: 'صفة الممثل القانوني',
|
||||
responsibleName: 'اسم المسؤول',
|
||||
responsibleRole: 'صفة المسؤول',
|
||||
responsibleIdentityNumber: 'رقم وثيقة الهوية',
|
||||
responsibleQualification: 'المؤهل / الشهادة / الخبرة',
|
||||
responsiblePhone: 'هاتف المسؤول',
|
||||
responsibleEmail: 'بريد المسؤول الإلكتروني',
|
||||
identitySection: 'الهوية القانونية',
|
||||
contactSection: 'بيانات الشركة',
|
||||
representativeSection: 'الممثل القانوني',
|
||||
responsibleSection: 'الشخص المسؤول',
|
||||
continue: 'متابعة',
|
||||
back: 'رجوع',
|
||||
working: 'جارٍ التنفيذ…',
|
||||
createWorkspace: 'إنشاء المساحة',
|
||||
workspaceReady: 'المساحة جاهزة',
|
||||
workspaceReadyBody: 'أصبحت الآن نشطة وتم تسجيل بريد المالك',
|
||||
enterDashboard: 'الدخول إلى لوحة التحكم',
|
||||
signInAnotherDevice: 'تسجيل الدخول على جهاز آخر',
|
||||
reviewWorkspace: 'المساحة',
|
||||
reviewPlan: 'الخطة',
|
||||
reviewOwnerEmail: 'بريد المالك',
|
||||
reviewCompanyEmail: 'بريد الشركة',
|
||||
reviewPhone: 'الهاتف',
|
||||
reviewLegalName: 'الاسم القانوني',
|
||||
reviewRegistration: 'RC / ICE / IF',
|
||||
invalidPassword: 'اختر كلمة مرور من 8 أحرف على الأقل.',
|
||||
passwordMismatch: 'كلمتا المرور غير متطابقتين.',
|
||||
missingRequired: 'أكمل جميع الحقول الإلزامية.',
|
||||
missingLanguage: 'يرجى اختيار اللغة المفضلة.',
|
||||
preferredLanguageLabel: 'اللغة المفضلة',
|
||||
languageOptions: { en: 'English', fr: 'Français', ar: 'العربية' } as Record<string, string>,
|
||||
companyFallback: 'شركتك',
|
||||
couldNotCreate: 'تعذر إنشاء المساحة',
|
||||
legalFormOptions: {
|
||||
'': 'اختر الشكل القانوني',
|
||||
SARL: 'شركة ذات مسؤولية محدودة',
|
||||
'SARL AU': 'شركة ذات مسؤولية محدودة لشخص واحد',
|
||||
SA: 'شركة مساهمة',
|
||||
SAS: 'شركة مساهمة مبسطة',
|
||||
AUTO_ENTREPRENEUR: 'مقاول ذاتي',
|
||||
EI: 'مؤسسة فردية',
|
||||
OTHER: 'أخرى',
|
||||
} as Record<string, string>,
|
||||
yearsActiveOptions: {
|
||||
'': 'اختر سنوات النشاط',
|
||||
LESS_THAN_1: 'أقل من سنة',
|
||||
ONE_TO_FIVE: 'من سنة إلى 5 سنوات',
|
||||
MORE_THAN_5: 'أكثر من 5 سنوات',
|
||||
} as Record<string, string>,
|
||||
planDescriptions: {
|
||||
STARTER: 'الأساسيات للأسطول والعروض والحجوزات.',
|
||||
GROWTH: 'ظهور مميز في السوق ومساحة أكبر للنمو.',
|
||||
PRO: 'تحكم كامل وواجهة بيضاء متقدمة.',
|
||||
} as Record<SignupForm['plan'], string>,
|
||||
billingPeriodOptions: {
|
||||
MONTHLY: 'شهري',
|
||||
ANNUAL: 'سنوي',
|
||||
} as Record<SignupForm['billingPeriod'], string>,
|
||||
paymentProviderOptions: {
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<SignupForm['paymentProvider'], string>,
|
||||
},
|
||||
}[language]
|
||||
|
||||
function handleStep1Continue() {
|
||||
if (!form.preferredLanguage) {
|
||||
setError(dict.missingLanguage)
|
||||
return
|
||||
}
|
||||
const ownerEmail = normalizeEmail(form.email)
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
companyEmail: current.companyEmail || ownerEmail,
|
||||
responsibleEmail: current.responsibleEmail || ownerEmail,
|
||||
}))
|
||||
setError(null)
|
||||
setStep(2)
|
||||
}
|
||||
|
||||
function handleStep2Continue() {
|
||||
if (!hasRequiredPartnerFields(form)) {
|
||||
setError(dict.missingRequired)
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setStep(3)
|
||||
}
|
||||
|
||||
async function submitSignup() {
|
||||
if (form.password.length < 8) {
|
||||
setError(dict.invalidPassword)
|
||||
return
|
||||
}
|
||||
|
||||
if (form.password !== form.confirmPassword) {
|
||||
setError(dict.passwordMismatch)
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasRequiredPartnerFields(form)) {
|
||||
setError(dict.missingRequired)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await apiFetch<SignupResponse>('/auth/company/signup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
firstName: form.firstName.fr || form.firstName.ar,
|
||||
firstNameAr: form.firstName.ar || undefined,
|
||||
lastName: form.lastName.fr || form.lastName.ar,
|
||||
lastNameAr: form.lastName.ar || undefined,
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
preferredLanguage: form.preferredLanguage || 'en',
|
||||
companyName: form.companyName.fr || form.companyName.ar,
|
||||
legalName: form.legalName,
|
||||
companyNameAr: form.companyName.ar || undefined,
|
||||
legalForm: form.legalForm,
|
||||
registrationNumber: form.registrationNumber,
|
||||
iceNumber: form.iceNumber,
|
||||
taxId: form.taxId,
|
||||
operatingLicenseNumber: form.operatingLicenseNumber,
|
||||
operatingLicenseIssuedAt: form.operatingLicenseIssuedAt,
|
||||
operatingLicenseIssuedBy: form.operatingLicenseIssuedBy,
|
||||
streetAddress: form.streetAddress.fr || form.streetAddress.ar,
|
||||
streetAddressAr: form.streetAddress.ar || undefined,
|
||||
city: form.city.fr || form.city.ar,
|
||||
cityAr: form.city.ar || undefined,
|
||||
country: form.country.fr || form.country.ar,
|
||||
countryAr: form.country.ar || undefined,
|
||||
zipCode: form.zipCode,
|
||||
companyPhone: form.companyPhone,
|
||||
companyEmail: form.companyEmail,
|
||||
fax: form.fax || undefined,
|
||||
yearsActive: form.yearsActive,
|
||||
representativeName: form.representativeName || undefined,
|
||||
representativeTitle: form.representativeTitle || undefined,
|
||||
responsibleName: form.responsibleName,
|
||||
responsibleRole: form.responsibleRole,
|
||||
responsibleIdentityNumber: form.responsibleIdentityNumber,
|
||||
responsibleQualification: form.responsibleQualification || undefined,
|
||||
responsiblePhone: form.responsiblePhone,
|
||||
responsibleEmail: form.responsibleEmail,
|
||||
plan: form.plan,
|
||||
billingPeriod: form.billingPeriod,
|
||||
currency: form.currency,
|
||||
paymentProvider: form.paymentProvider,
|
||||
}),
|
||||
})
|
||||
|
||||
setCompletedSignup({
|
||||
companyName: form.companyName.fr || form.companyName.ar,
|
||||
email: form.email,
|
||||
emailWarning: getEmailWarning(response.emailDelivery, language),
|
||||
})
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, dict.couldNotCreate))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (completedSignup) {
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="flex-1 px-4 py-16">
|
||||
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
|
||||
<div className="flex justify-center">
|
||||
<a href={marketplaceUrl} target="_top">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
width={104}
|
||||
height={104}
|
||||
priority
|
||||
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-blue-950"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<a href={marketplaceUrl} className="mt-4 inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
|
||||
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">{dict.workspaceReady}</h1>
|
||||
<p className="mt-4 text-base leading-7 text-slate-600">
|
||||
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> {dict.workspaceReadyBody}
|
||||
<span className="font-semibold text-slate-900"> {completedSignup.email}</span>.
|
||||
</p>
|
||||
{completedSignup.emailWarning ? (
|
||||
<div className="mt-6 rounded-2xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-800">
|
||||
{completedSignup.emailWarning}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Link href="/" className="btn-primary justify-center">
|
||||
{dict.enterDashboard}
|
||||
</Link>
|
||||
<a href="/sign-in" className="btn-secondary justify-center">
|
||||
{dict.signInAnotherDevice}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="px-4 py-12">
|
||||
<div className="mx-auto max-w-4xl space-y-8">
|
||||
<div className={`text-center ${isArabic ? 'rtl' : ''}`}>
|
||||
<div className="flex justify-center">
|
||||
<a href={marketplaceUrl} target="_top">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
width={104}
|
||||
height={104}
|
||||
priority
|
||||
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-blue-950"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<a href={marketplaceUrl} className="mt-4 inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
|
||||
<h1 className="mt-3 text-5xl font-black tracking-tight text-slate-900">{dict.pageTitle}</h1>
|
||||
<p className="mt-4 text-base leading-7 text-slate-600">{dict.pageSubtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
{dict.steps.map((label, index) => (
|
||||
<div key={label} className={`rounded-full px-4 py-2 text-center text-sm font-semibold ${index + 1 <= step ? 'bg-blue-900 text-white' : 'border border-slate-200 bg-white text-slate-500'}`}>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
|
||||
{error ? <div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
{step === 1 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title={dict.ownerTitle} body={dict.ownerBody} />
|
||||
<BilingualInput label={dict.firstName} required maxLength={80} value={form.firstName} onChange={(value) => setForm((current) => ({ ...current, firstName: value }))} />
|
||||
<BilingualInput label={dict.lastName} required maxLength={80} value={form.lastName} onChange={(current) => setForm((f) => ({ ...f, lastName: current }))} />
|
||||
<LabeledInput label={dict.ownerEmail} required type="email" maxLength={254} value={form.email} onChange={(value) => setForm((current) => ({ ...current, email: normalizeEmail(value) }))} />
|
||||
<p className="text-xs text-slate-500">{dict.emailReuseHint}</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.password} required type="password" maxLength={128} value={form.password} onChange={(value) => setForm((current) => ({ ...current, password: value }))} />
|
||||
<LabeledInput label={dict.confirmPassword} required type="password" maxLength={128} value={form.confirmPassword} onChange={(value) => setForm((current) => ({ ...current, confirmPassword: value }))} />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">{dict.passwordHint}</p>
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
{dict.preferredLanguageLabel} <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['en', 'fr', 'ar'] as const).map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setForm((current) => ({ ...current, preferredLanguage: lang }))
|
||||
setLanguage(lang)
|
||||
}}
|
||||
className={`rounded-2xl border py-3 text-sm font-semibold transition ${
|
||||
form.preferredLanguage === lang
|
||||
? 'border-blue-900 bg-blue-900 text-white'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{dict.languageOptions[lang]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={handleStep1Continue} className="btn-primary">{dict.continue}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 2 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title={dict.partnerTitle} body={dict.partnerBody} />
|
||||
<div className="space-y-4">
|
||||
<FormSubsection title={dict.identitySection} />
|
||||
<BilingualInput label={dict.companyName} required maxLength={120} value={form.companyName} onChange={(value) => setForm((current) => ({ ...current, companyName: value }))} />
|
||||
<LabeledInput label={dict.legalName} required maxLength={160} value={form.legalName} onChange={(value) => setForm((current) => ({ ...current, legalName: normalizeTrimmed(value) }))} />
|
||||
<LabeledSelect label={dict.legalForm} required value={form.legalForm} options={['', ...LEGAL_FORM_OPTIONS]} labels={dict.legalFormOptions} onChange={(value) => setForm((current) => ({ ...current, legalForm: value }))} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.registrationNumber} required maxLength={120} value={form.registrationNumber} onChange={(value) => setForm((current) => ({ ...current, registrationNumber: normalizeUpper(value) }))} />
|
||||
<LabeledInput label={dict.iceNumber} required maxLength={120} value={form.iceNumber} onChange={(value) => setForm((current) => ({ ...current, iceNumber: normalizeUpper(value) }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.taxId} required maxLength={120} value={form.taxId} onChange={(value) => setForm((current) => ({ ...current, taxId: normalizeUpper(value) }))} />
|
||||
<LabeledInput label={dict.operatingLicenseNumber} required maxLength={120} value={form.operatingLicenseNumber} onChange={(value) => setForm((current) => ({ ...current, operatingLicenseNumber: normalizeUpper(value) }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.operatingLicenseIssuedAt} required type="date" value={form.operatingLicenseIssuedAt} onChange={(value) => setForm((current) => ({ ...current, operatingLicenseIssuedAt: value }))} />
|
||||
<LabeledInput label={dict.operatingLicenseIssuedBy} required maxLength={160} value={form.operatingLicenseIssuedBy} onChange={(value) => setForm((current) => ({ ...current, operatingLicenseIssuedBy: normalizeTrimmed(value) }))} />
|
||||
</div>
|
||||
|
||||
<FormSubsection title={dict.contactSection} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.companyPhone} required maxLength={80} value={form.companyPhone} onChange={(value) => setForm((current) => ({ ...current, companyPhone: value }))} />
|
||||
<LabeledInput label={dict.companyEmail} required type="email" maxLength={254} value={form.companyEmail} onChange={(value) => setForm((current) => ({ ...current, companyEmail: normalizeEmail(value) }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.fax} maxLength={80} value={form.fax} onChange={(value) => setForm((current) => ({ ...current, fax: value }))} />
|
||||
<LabeledSelect label={dict.yearsActive} required value={form.yearsActive} options={['', ...YEARS_ACTIVE_OPTIONS]} labels={dict.yearsActiveOptions} onChange={(value) => setForm((current) => ({ ...current, yearsActive: value }))} />
|
||||
</div>
|
||||
<BilingualInput label={dict.streetAddress} required maxLength={200} value={form.streetAddress} onChange={(value) => setForm((current) => ({ ...current, streetAddress: value }))} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<BilingualInput label={dict.city} required maxLength={120} value={form.city} onChange={(value) => setForm((current) => ({ ...current, city: value }))} />
|
||||
<BilingualInput label={dict.country} required maxLength={120} value={form.country} onChange={(value) => setForm((current) => ({ ...current, country: value }))} />
|
||||
</div>
|
||||
<LabeledInput label={dict.zipCode} required maxLength={40} value={form.zipCode} onChange={(value) => setForm((current) => ({ ...current, zipCode: value.toUpperCase() }))} />
|
||||
|
||||
<FormSubsection title={dict.representativeSection} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.representativeName} maxLength={160} value={form.representativeName} onChange={(value) => setForm((current) => ({ ...current, representativeName: normalizeTrimmed(value) }))} />
|
||||
<LabeledInput label={dict.representativeTitle} maxLength={120} value={form.representativeTitle} onChange={(value) => setForm((current) => ({ ...current, representativeTitle: normalizeTrimmed(value) }))} />
|
||||
</div>
|
||||
|
||||
<FormSubsection title={dict.responsibleSection} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.responsibleName} required maxLength={160} value={form.responsibleName} onChange={(value) => setForm((current) => ({ ...current, responsibleName: normalizeTrimmed(value) }))} />
|
||||
<LabeledInput label={dict.responsibleRole} required maxLength={120} value={form.responsibleRole} onChange={(value) => setForm((current) => ({ ...current, responsibleRole: normalizeTrimmed(value) }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.responsibleIdentityNumber} required maxLength={120} value={form.responsibleIdentityNumber} onChange={(value) => setForm((current) => ({ ...current, responsibleIdentityNumber: normalizeUpper(value) }))} />
|
||||
<LabeledInput label={dict.responsibleQualification} maxLength={200} value={form.responsibleQualification} onChange={(value) => setForm((current) => ({ ...current, responsibleQualification: normalizeTrimmed(value) }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.responsiblePhone} required maxLength={80} value={form.responsiblePhone} onChange={(value) => setForm((current) => ({ ...current, responsiblePhone: value }))} />
|
||||
<LabeledInput label={dict.responsibleEmail} required type="email" maxLength={254} value={form.responsibleEmail} onChange={(value) => setForm((current) => ({ ...current, responsibleEmail: normalizeEmail(value) }))} />
|
||||
</div>
|
||||
</div>
|
||||
<NavActions onBack={() => setStep(1)} onNext={handleStep2Continue} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 3 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title={dict.planTitle} body={dict.planBody} />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{(['STARTER', 'GROWTH', 'PRO'] as const).map((plan) => (
|
||||
<button
|
||||
key={plan}
|
||||
type="button"
|
||||
onClick={() => setForm((current) => ({ ...current, plan }))}
|
||||
className={`rounded-3xl border p-5 text-left ${form.plan === plan ? 'border-blue-900 bg-blue-900 text-white' : 'border-slate-200 bg-white text-slate-900'}`}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em]">{plan}</p>
|
||||
<p className="mt-3 text-sm opacity-80">{dict.planDescriptions[plan]}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledSelect label={language === 'fr' ? 'Période de facturation' : language === 'ar' ? 'فترة الفوترة' : 'Billing period'} value={form.billingPeriod} options={['MONTHLY', 'ANNUAL']} labels={dict.billingPeriodOptions} onChange={(value) => setForm((current) => ({ ...current, billingPeriod: value as SignupForm['billingPeriod'] }))} />
|
||||
<LabeledSelect label={language === 'fr' ? 'Prestataire principal' : language === 'ar' ? 'مزود الدفع الرئيسي' : 'Primary provider'} value={form.paymentProvider} options={['AMANPAY', 'PAYPAL']} labels={dict.paymentProviderOptions} onChange={(value) => setForm((current) => ({ ...current, paymentProvider: value as SignupForm['paymentProvider'] }))} />
|
||||
</div>
|
||||
<NavActions onBack={() => setStep(2)} onNext={() => setStep(4)} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 4 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title={dict.reviewTitle} body={dict.reviewBody} />
|
||||
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
|
||||
<p><span className="font-semibold text-slate-900">{dict.reviewWorkspace}:</span> {form.companyName.fr || form.companyName.ar || dict.companyFallback}{form.companyName.fr && form.companyName.ar ? <span className="ml-2 text-slate-400">/ {form.companyName.ar}</span> : null}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewLegalName}:</span> {form.legalName || '—'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPlan}:</span> {form.plan} · {dict.billingPeriodOptions[form.billingPeriod]} · {getCurrencyLabel(language)}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewOwnerEmail}:</span> {form.email || '—'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewCompanyEmail}:</span> {form.companyEmail || '—'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPhone}:</span> {form.companyPhone || '—'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewRegistration}:</span> {form.registrationNumber || '—'} / {form.iceNumber || '—'} / {form.taxId || '—'}</p>
|
||||
</div>
|
||||
<NavActions
|
||||
onBack={() => setStep(3)}
|
||||
onNext={submitSignup}
|
||||
loading={loading}
|
||||
nextLabel={dict.createWorkspace}
|
||||
backLabel={dict.back}
|
||||
loadingLabel={dict.working}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
|
||||
function hasRequiredPartnerFields(form: SignupForm) {
|
||||
const bilingualFilled = (f: BilingualField) => f.fr.trim().length > 0 || f.ar.trim().length > 0
|
||||
return (
|
||||
bilingualFilled(form.companyName) &&
|
||||
bilingualFilled(form.streetAddress) &&
|
||||
bilingualFilled(form.city) &&
|
||||
bilingualFilled(form.country) &&
|
||||
[
|
||||
form.legalName,
|
||||
form.legalForm,
|
||||
form.registrationNumber,
|
||||
form.iceNumber,
|
||||
form.taxId,
|
||||
form.operatingLicenseNumber,
|
||||
form.operatingLicenseIssuedAt,
|
||||
form.operatingLicenseIssuedBy,
|
||||
form.zipCode,
|
||||
form.companyPhone,
|
||||
form.companyEmail,
|
||||
form.yearsActive,
|
||||
form.responsibleName,
|
||||
form.responsibleRole,
|
||||
form.responsibleIdentityNumber,
|
||||
form.responsiblePhone,
|
||||
form.responsibleEmail,
|
||||
]
|
||||
.every((v) => v.trim().length > 0)
|
||||
)
|
||||
}
|
||||
|
||||
function SectionIntro({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{title}</h2>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-500">{body}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FormSubsection({ title }: { title: string }) {
|
||||
return <h3 className="pt-2 text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">{title}</h3>
|
||||
}
|
||||
|
||||
function getEmailWarning(emailDelivery: SignupResponse['emailDelivery'], language: 'en' | 'fr' | 'ar') {
|
||||
if (!emailDelivery) return null
|
||||
if (emailDelivery.success) return null
|
||||
if (emailDelivery.attempted) {
|
||||
if (language === 'fr') return `Le compte a été créé, mais l’e-mail de confirmation n’a pas pu être envoyé${emailDelivery.error ? ` : ${emailDelivery.error}` : '.'}`
|
||||
if (language === 'ar') return `تم إنشاء الحساب، لكن تعذر إرسال رسالة التأكيد${emailDelivery.error ? `: ${emailDelivery.error}` : '.'}`
|
||||
return `The account was created, but the confirmation email could not be delivered${emailDelivery.error ? `: ${emailDelivery.error}` : '.'}`
|
||||
}
|
||||
if (language === 'fr') return 'Le compte a été créé, mais l’envoi d’e-mails n’est pas encore configuré sur cet environnement.'
|
||||
if (language === 'ar') return 'تم إنشاء الحساب، لكن إرسال البريد الإلكتروني غير مهيأ بعد في هذه البيئة.'
|
||||
return 'The account was created, but email sending is not configured on this environment yet.'
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizeEmail(value: string) {
|
||||
return value.replace(/\s+/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeTrimmed(value: string) {
|
||||
return value.replace(/^\s+/, '')
|
||||
}
|
||||
|
||||
function normalizeUpper(value: string) {
|
||||
return value.replace(/^\s+/, '').toUpperCase()
|
||||
}
|
||||
|
||||
function LabeledInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = 'text',
|
||||
required = false,
|
||||
maxLength,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
type?: string
|
||||
required?: boolean
|
||||
maxLength?: number
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
{label}
|
||||
{required ? <span className="ml-1 text-red-600">*</span> : null}
|
||||
</span>
|
||||
<input type={type} maxLength={maxLength} className="input-field" value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function LabeledSelect({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
labels,
|
||||
required = false,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: readonly string[]
|
||||
onChange: (value: string) => void
|
||||
labels?: Record<string, string>
|
||||
required?: boolean
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
{label}
|
||||
{required ? <span className="ml-1 text-red-600">*</span> : null}
|
||||
</span>
|
||||
<select className="input-field" value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map((option) => (
|
||||
<option key={option || 'empty'} value={option}>{labels?.[option] ?? option}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function NavActions({
|
||||
onBack,
|
||||
onNext,
|
||||
loading = false,
|
||||
nextLabel = 'Continue',
|
||||
backLabel = 'Back',
|
||||
loadingLabel = 'Working…',
|
||||
}: {
|
||||
onBack?: () => void
|
||||
onNext: () => void
|
||||
loading?: boolean
|
||||
nextLabel?: string
|
||||
backLabel?: string
|
||||
loadingLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
{onBack ? (
|
||||
<button type="button" onClick={onBack} className="btn-secondary flex-1 justify-center">
|
||||
{backLabel}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={onNext} disabled={loading} className="btn-primary flex-1 justify-center disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{loading ? loadingLabel : nextLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePlan(value: string | null): SignupForm['plan'] {
|
||||
if (value === 'GROWTH' || value === 'PRO' || value === 'STARTER') return value
|
||||
return 'STARTER'
|
||||
}
|
||||
|
||||
function normalizeBillingPeriod(value: string | null): SignupForm['billingPeriod'] {
|
||||
return value === 'ANNUAL' ? 'ANNUAL' : 'MONTHLY'
|
||||
}
|
||||
|
||||
function normalizeCurrency(_value: string | null): SignupForm['currency'] {
|
||||
return 'MAD'
|
||||
}
|
||||
Reference in New Issue
Block a user