fix notification and add billing page and contract
This commit is contained in:
@@ -1,465 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import Link from 'next/link'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
|
||||
type Currency = 'MAD' | 'USD' | 'EUR'
|
||||
|
||||
interface Subscription {
|
||||
id: string
|
||||
plan: Plan
|
||||
billingPeriod: BillingPeriod
|
||||
status: string
|
||||
currency: Currency
|
||||
trialEndAt: string | null
|
||||
currentPeriodEnd: string | null
|
||||
cancelAtPeriodEnd: boolean
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
type PaymentRow = {
|
||||
id: string
|
||||
amount: number
|
||||
currency: Currency
|
||||
status: string
|
||||
paymentProvider: string
|
||||
type: 'CHARGE' | 'DEPOSIT'
|
||||
paymentProvider: 'AMANPAY' | 'PAYPAL'
|
||||
paidAt: string | null
|
||||
createdAt: string
|
||||
reservation: {
|
||||
id: string
|
||||
customer: { firstName: string; lastName: string }
|
||||
vehicle: { make: string; model: string; licensePlate: string }
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
TRIALING: 'bg-sky-100 text-sky-700',
|
||||
ACTIVE: 'bg-green-100 text-green-700',
|
||||
PAST_DUE: 'bg-amber-100 text-amber-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-amber-100 text-amber-700',
|
||||
SUCCEEDED: 'bg-green-100 text-green-700',
|
||||
FAILED: 'bg-red-100 text-red-700',
|
||||
REFUNDED: 'bg-slate-100 text-slate-600',
|
||||
PARTIALLY_REFUNDED: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
|
||||
export default function BillingPage() {
|
||||
export default function RentCarBillingPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const [subscription, setSubscription] = useState<Subscription | null>(null)
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [payments, setPayments] = useState<PaymentRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
|
||||
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
|
||||
const [currency, setCurrency] = useState<Currency>('MAD')
|
||||
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
|
||||
const [paying, setPaying] = useState(false)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Billing',
|
||||
subtitle: 'Manage your plan, payment provider, and invoice history.',
|
||||
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',
|
||||
title: 'Rent Car Billing',
|
||||
subtitle: 'Track rental payment transactions across all reservations.',
|
||||
bookCar: 'Book car',
|
||||
loading: 'Loading payments…',
|
||||
empty: 'No rental payments yet.',
|
||||
failed: 'Failed to load rental payments.',
|
||||
reservation: 'Reservation',
|
||||
customer: 'Customer',
|
||||
vehicle: 'Vehicle',
|
||||
type: 'Type',
|
||||
provider: 'Provider',
|
||||
status: 'Status',
|
||||
paid: 'Paid',
|
||||
created: 'Created',
|
||||
paidAt: 'Paid at',
|
||||
amount: 'Amount',
|
||||
loading: 'Loading…',
|
||||
noInvoices: 'No invoices yet.',
|
||||
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[]>,
|
||||
charge: 'Charge',
|
||||
deposit: 'Deposit',
|
||||
},
|
||||
fr: {
|
||||
title: 'Facturation',
|
||||
subtitle: 'Gérez votre plan, le prestataire de paiement et l’historique des factures.',
|
||||
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',
|
||||
title: 'Facturation location',
|
||||
subtitle: 'Suivez les paiements de location sur toutes les réservations.',
|
||||
bookCar: 'Réserver une voiture',
|
||||
loading: 'Chargement des paiements…',
|
||||
empty: 'Aucun paiement de location pour le moment.',
|
||||
failed: 'Échec du chargement des paiements de location.',
|
||||
reservation: 'Réservation',
|
||||
customer: 'Client',
|
||||
vehicle: 'Véhicule',
|
||||
type: 'Type',
|
||||
provider: 'Prestataire',
|
||||
status: 'Statut',
|
||||
paid: 'Payé',
|
||||
created: 'Créé le',
|
||||
paidAt: 'Payé le',
|
||||
amount: 'Montant',
|
||||
loading: 'Chargement…',
|
||||
noInvoices: 'Aucune facture pour le moment.',
|
||||
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[]>,
|
||||
charge: 'Paiement',
|
||||
deposit: 'Dépôt',
|
||||
},
|
||||
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: 'التاريخ',
|
||||
title: 'فوترة تأجير السيارات',
|
||||
subtitle: 'تتبع معاملات دفع الإيجار عبر جميع الحجوزات.',
|
||||
bookCar: 'حجز سيارة',
|
||||
loading: 'جارٍ تحميل المدفوعات…',
|
||||
empty: 'لا توجد مدفوعات تأجير حتى الآن.',
|
||||
failed: 'فشل تحميل مدفوعات التأجير.',
|
||||
reservation: 'الحجز',
|
||||
customer: 'العميل',
|
||||
vehicle: 'المركبة',
|
||||
type: 'النوع',
|
||||
provider: 'المزوّد',
|
||||
status: 'الحالة',
|
||||
paid: 'مدفوع',
|
||||
created: 'تاريخ الإنشاء',
|
||||
paidAt: 'تاريخ الدفع',
|
||||
amount: 'المبلغ',
|
||||
loading: 'جارٍ التحميل…',
|
||||
noInvoices: 'لا توجد فواتير حتى الآن.',
|
||||
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[]>,
|
||||
charge: 'دفعة',
|
||||
deposit: 'عربون',
|
||||
},
|
||||
}[language]
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
apiFetch<Subscription | null>('/subscriptions/me'),
|
||||
apiFetch<Invoice[]>('/subscriptions/invoices'),
|
||||
])
|
||||
.then(([sub, inv]) => {
|
||||
if (sub) {
|
||||
setSubscription(sub)
|
||||
setSelectedPlan(sub.plan)
|
||||
setBillingPeriod(sub.billingPeriod)
|
||||
setCurrency(sub.currency as Currency)
|
||||
}
|
||||
setInvoices(inv ?? [])
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
apiFetch<PaymentRow[]>('/payments/company')
|
||||
.then((rows) => setPayments(rows ?? []))
|
||||
.catch((err) => setError(err.message ?? copy.failed))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function handleCheckout() {
|
||||
setPaying(true)
|
||||
setError(null)
|
||||
try {
|
||||
const base = window.location.origin
|
||||
const result = await apiFetch<{ checkoutUrl: string }>('/subscriptions/checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
plan: selectedPlan,
|
||||
billingPeriod,
|
||||
currency,
|
||||
provider,
|
||||
successUrl: `${base}/dashboard/billing?payment=success`,
|
||||
failureUrl: `${base}/dashboard/billing?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 = PLAN_PRICES[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 className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<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>
|
||||
<Link href="/dashboard/reservations/new" className="btn-primary whitespace-nowrap">
|
||||
{copy.bookCar}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="card p-4 border-red-200 bg-red-50 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
{error ? <div className="card p-4 text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
{/* 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-amber-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">
|
||||
<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-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{p === 'MONTHLY' ? copy.monthly : copy.annual}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Currency selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
{(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCurrency(c)}
|
||||
className={`px-3 py-1 rounded-lg text-sm font-medium border transition-colors ${
|
||||
currency === c ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Plan cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{PLANS.map((plan) => {
|
||||
const price = PLAN_PRICES[plan]?.[billingPeriod]?.[currency]
|
||||
const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE'
|
||||
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, currency) : '—'}
|
||||
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1">
|
||||
{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">
|
||||
{(['AMANPAY', 'PAYPAL'] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setProvider(p)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
provider === p ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{p === 'AMANPAY' ? '🏦 AmanPay' : '🔵 PayPal'}
|
||||
</button>
|
||||
))}
|
||||
</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, currency) : '—'}
|
||||
<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}
|
||||
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.reservation}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.customer}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.vehicle}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.type}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.provider}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.status}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.paid}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.created}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.paidAt}</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.amount}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
<tr><td colSpan={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>
|
||||
<tr><td colSpan={9} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
|
||||
) : payments.length === 0 ? (
|
||||
<tr><td colSpan={9} className="px-6 py-10 text-center text-sm text-slate-400">{copy.empty}</td></tr>
|
||||
) : payments.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="px-6 py-4 text-sm font-medium text-slate-900">#{row.reservation.id.slice(0, 8)}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.reservation.customer.firstName} {row.reservation.customer.lastName}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.reservation.vehicle.make} {row.reservation.vehicle.model} · {row.reservation.vehicle.licensePlate}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.type === 'DEPOSIT' ? copy.deposit : copy.charge}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.paymentProvider}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS[inv.status] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||
{copy.invoiceStatusLabels[inv.status] ?? inv.status}
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[row.status] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||
{row.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">
|
||||
{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, inv.currency)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">{new Date(row.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">{row.paidAt ? new Date(row.paidAt).toLocaleDateString() : '—'}</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.amount, row.currency)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,954 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
type DamagePoint = {
|
||||
id?: string
|
||||
x: number
|
||||
y: number
|
||||
damageType: 'SCRATCH' | 'DENT' | 'CRACK' | 'CHIP' | 'MISSING' | 'STAIN' | 'OTHER'
|
||||
severity: 'MINOR' | 'MODERATE' | 'MAJOR'
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
type ContractPayload = {
|
||||
reservationId: string
|
||||
contractNumber: string | null
|
||||
invoiceNumber: string | null
|
||||
generatedAt: string
|
||||
status: string
|
||||
paymentStatus: string
|
||||
paymentMode: string | null
|
||||
notes: string | null
|
||||
company: {
|
||||
name: string
|
||||
legalName: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
address: string | Record<string, unknown> | null
|
||||
city: string | null
|
||||
country: string | null
|
||||
registrationNumber: string | null
|
||||
taxId: string | null
|
||||
logoUrl: string | null
|
||||
}
|
||||
driver: {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string | null
|
||||
dateOfBirth: string | null
|
||||
driverLicense: string | null
|
||||
licenseCountry: string | null
|
||||
licenseCategory: string | null
|
||||
licenseIssuedAt: string | null
|
||||
licenseExpiry: string | null
|
||||
nationality: string | null
|
||||
}
|
||||
additionalDrivers: Array<{
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
driverLicense: string
|
||||
licenseIssuedAt: string | null
|
||||
licenseExpiry: string | null
|
||||
licenseCountry: string | null
|
||||
dateOfBirth: string | null
|
||||
totalCharge: number
|
||||
}>
|
||||
vehicle: {
|
||||
make: string
|
||||
model: string
|
||||
year: number
|
||||
color: string
|
||||
licensePlate: string
|
||||
vin: string | null
|
||||
category: string
|
||||
}
|
||||
rentalPeriod: {
|
||||
startDate: string
|
||||
endDate: string
|
||||
totalDays: number
|
||||
pickupLocation: string | null
|
||||
returnLocation: string | null
|
||||
}
|
||||
insurance: {
|
||||
total: number
|
||||
policies: Array<{
|
||||
id: string
|
||||
name: string
|
||||
chargeType: string
|
||||
unitPrice: number
|
||||
totalCharge: number
|
||||
}>
|
||||
}
|
||||
inspections: {
|
||||
checkIn: {
|
||||
mileage: number | null
|
||||
fuelLevel: string
|
||||
generalCondition: string | null
|
||||
employeeNotes: string | null
|
||||
damagePoints: DamagePoint[]
|
||||
} | null
|
||||
checkOut: {
|
||||
mileage: number | null
|
||||
fuelLevel: string
|
||||
generalCondition: string | null
|
||||
employeeNotes: string | null
|
||||
damagePoints: DamagePoint[]
|
||||
} | null
|
||||
}
|
||||
terms: {
|
||||
terms: string | null
|
||||
fuelPolicy: string | null
|
||||
fuelPolicyType: string | null
|
||||
depositPolicy: string | null
|
||||
lateFeePolicy: string | null
|
||||
damagePolicy: string | null
|
||||
footerNote: string | null
|
||||
signatureRequired: boolean
|
||||
}
|
||||
invoice: {
|
||||
currency: string
|
||||
lineItems: Array<{
|
||||
description: string
|
||||
qty: number
|
||||
unitPrice: number
|
||||
total: number
|
||||
category: string
|
||||
}>
|
||||
subtotal: number
|
||||
taxes: Array<{ label: string; rate: number; amount: number }>
|
||||
taxTotal: number
|
||||
total: number
|
||||
amountPaid: number
|
||||
balanceDue: number
|
||||
payments: Array<{
|
||||
id: string
|
||||
amount: number
|
||||
currency: string
|
||||
type: string
|
||||
status: string
|
||||
provider: string
|
||||
paymentMethod: string | null
|
||||
paidAt: string | null
|
||||
createdAt: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
const severityColors: Record<DamagePoint['severity'], string> = {
|
||||
MINOR: '#f59e0b',
|
||||
MODERATE: '#f97316',
|
||||
MAJOR: '#dc2626',
|
||||
}
|
||||
|
||||
function VehicleDamageDiagram({ points }: { points: DamagePoint[] }) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 180" className="w-full max-w-[120px] rounded-2xl border border-slate-200 bg-white p-2">
|
||||
<rect x="30" y="12" width="40" height="156" rx="18" fill="#e2e8f0" stroke="#94a3b8" />
|
||||
<rect x="36" y="22" width="28" height="24" rx="8" fill="#cbd5e1" />
|
||||
<rect x="36" y="54" width="28" height="70" rx="8" fill="#f8fafc" stroke="#cbd5e1" />
|
||||
<rect x="36" y="132" width="28" height="20" rx="8" fill="#cbd5e1" />
|
||||
<rect x="18" y="38" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
<rect x="70" y="38" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
<rect x="18" y="114" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
<rect x="70" y="114" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
{points.map((point, index) => (
|
||||
<circle
|
||||
key={`${point.x}-${point.y}-${index}`}
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r="4.2"
|
||||
fill={severityColors[point.severity]}
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function formatAddress(address: string | Record<string, unknown> | null, city: string | null, country: string | null) {
|
||||
const lines: string[] = []
|
||||
if (typeof address === 'string' && address.trim()) lines.push(address.trim())
|
||||
if (address && typeof address === 'object' && !Array.isArray(address)) {
|
||||
for (const value of Object.values(address)) {
|
||||
if (typeof value === 'string' && value.trim()) lines.push(value.trim())
|
||||
}
|
||||
}
|
||||
if (city) lines.push(city)
|
||||
if (country) lines.push(country)
|
||||
return lines.join(', ')
|
||||
}
|
||||
|
||||
function formatDateOnly(value: string | null | undefined, localeCode: string, fallback: string) {
|
||||
return value
|
||||
? new Date(value).toLocaleDateString(localeCode, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
: fallback
|
||||
}
|
||||
|
||||
function splitTerms(text: string | null | undefined) {
|
||||
if (!text?.trim()) return []
|
||||
return text
|
||||
.split(/\n\s*\n|\n(?=\d+\.)/)
|
||||
.map((item) => item.replace(/^\d+\.\s*/, '').trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function PaperField({
|
||||
label,
|
||||
value,
|
||||
className = '',
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`border border-stone-400/80 ${className}`}>
|
||||
<div className="border-b border-stone-300 bg-stone-100 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-stone-600">
|
||||
{label}
|
||||
</div>
|
||||
<div className="min-h-[38px] bg-[#fff5ea] px-2 py-2 text-sm text-stone-900">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PaperSection({
|
||||
title,
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<section className={`border border-stone-500 ${className}`}>
|
||||
<div className="border-b border-stone-500 bg-stone-100 px-3 py-1.5 text-[11px] font-bold uppercase tracking-[0.12em] text-stone-700">
|
||||
{title}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ContractDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { language } = useDashboardI18n()
|
||||
const [contract, setContract] = useState<ContractPayload | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
back: 'Back to contracts',
|
||||
booking: 'Booking',
|
||||
print: 'Print',
|
||||
generated: 'Generated',
|
||||
status: 'Status',
|
||||
paymentStatus: 'Payment status',
|
||||
paymentMode: 'Payment mode',
|
||||
company: 'Rental company',
|
||||
driver: 'Primary driver',
|
||||
additionalDrivers: 'Additional drivers',
|
||||
vehicle: 'Vehicle',
|
||||
period: 'Rental period',
|
||||
pickup: 'Pickup',
|
||||
dropoff: 'Return',
|
||||
start: 'Start',
|
||||
end: 'End',
|
||||
contractTerms: 'Contract terms',
|
||||
insurance: 'Insurance',
|
||||
inspections: 'Vehicle condition and damage',
|
||||
noDamage: 'No damage markers recorded at check-in.',
|
||||
invoice: 'Invoice',
|
||||
subtotal: 'Subtotal',
|
||||
paid: 'Paid',
|
||||
balance: 'Balance due',
|
||||
payments: 'Payments',
|
||||
notes: 'Booking notes',
|
||||
footer: 'Footer note',
|
||||
none: 'Not provided',
|
||||
contractNo: 'Contract #',
|
||||
invoiceNo: 'Invoice #',
|
||||
fuel: 'Fuel policy',
|
||||
deposit: 'Deposit policy',
|
||||
lateFees: 'Late fee policy',
|
||||
damage: 'Damage policy',
|
||||
termsLabel: 'Terms',
|
||||
signature: 'Signature required',
|
||||
customerDamage: 'Check-in inspection',
|
||||
mileage: 'Mileage',
|
||||
fuelLevel: 'Fuel level',
|
||||
unitPrice: 'Unit price',
|
||||
qty: 'Qty',
|
||||
total: 'Total',
|
||||
actionOpenBooking: 'Open booking',
|
||||
noAdditionalDrivers: 'No additional drivers.',
|
||||
noInsurance: 'No insurance selected.',
|
||||
noPayments: 'No payments recorded.',
|
||||
loading: 'Loading contract…',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
agreementTitle: 'Rental Agreement',
|
||||
agreementForReservation: 'For reservation',
|
||||
draftAgreement: 'Draft agreement',
|
||||
customerInformation: 'Customer information',
|
||||
rentalPeriodDetails: 'Rental period and driver details',
|
||||
insuranceCondition: 'Insurance and vehicle condition',
|
||||
acknowledgementSignature: 'Driver acknowledgement and signature',
|
||||
vehicleInformation: 'Rental vehicle information',
|
||||
rentalRate: 'Car rental rate',
|
||||
billingSummary: 'Payment and billing summary',
|
||||
damageDiagram: 'Vehicle damage diagram',
|
||||
customerName: 'Customer name',
|
||||
homeAddress: 'Home address',
|
||||
cityCountry: 'City / Country',
|
||||
telephone: 'Telephone',
|
||||
emailLabel: 'E-mail',
|
||||
driverLicenseNumber: "Driver's license no.",
|
||||
nationalityLabel: 'Nationality',
|
||||
birthDate: 'Birth date',
|
||||
issuedExpires: 'Issued / Expires',
|
||||
dateOut: 'Date out',
|
||||
dateDueIn: 'Date due in',
|
||||
mileageOut: 'Mileage out',
|
||||
mileageIn: 'Mileage in',
|
||||
pickupLocationLabel: 'Pickup location',
|
||||
returnLocationLabel: 'Return location',
|
||||
insuranceSelections: 'Insurance selections',
|
||||
checkInCondition: 'Check-in condition',
|
||||
signatureAcknowledgement:
|
||||
'The renter confirms that the vehicle was received in acceptable condition, agrees to return it according to the rental policies, and accepts responsibility for authorized charges, violations, late fees, fuel adjustments, and damage assessment tied to this reservation.',
|
||||
customerSignature: 'Customer signature',
|
||||
companyRepresentative: 'Company representative',
|
||||
yearMakeModel: 'Year / Make / Model',
|
||||
licensePlateLabel: 'License plate',
|
||||
vehicleColor: 'Vehicle color',
|
||||
categoryLabel: 'Category',
|
||||
vinLabel: 'VIN',
|
||||
dailyRate: 'Daily rate',
|
||||
rentalDays: 'Rental days',
|
||||
insuranceTotal: 'Insurance total',
|
||||
additionalDriverFees: 'Additional driver fees',
|
||||
amountPaidLabel: 'Amount paid',
|
||||
balanceDueLabel: 'Balance due',
|
||||
lastPayment: 'Last payment',
|
||||
roadsideAssistance: 'For after-hours roadside assistance or towing call:',
|
||||
taxesLabel: 'Taxes',
|
||||
totalChargeLabel: 'Total charge',
|
||||
damageMarkersRecorded: (count: number) => `${count} damage marker(s) recorded at check-in.`,
|
||||
termsAndConditions: 'Rental Agreement Terms and Conditions',
|
||||
checkInMileageFuel: 'Check-in mileage / fuel',
|
||||
checkOutMileageFuel: 'Check-out mileage / fuel',
|
||||
damageEntries: 'Damage entries',
|
||||
notesAndFooter: 'Notes and footer',
|
||||
paymentsRecorded: (count: number) => `${count} payment(s) recorded.`,
|
||||
clauseHeadings: [
|
||||
'Vehicle and reservation',
|
||||
'Authorized use',
|
||||
'Fuel and return condition',
|
||||
'Damage liability',
|
||||
'Deposit and security hold',
|
||||
'Late return and extra charges',
|
||||
'Payment authorization',
|
||||
'General provisions',
|
||||
],
|
||||
fallbackClauses: {
|
||||
vehicleReservation: (vehicle: string) => `The renter accepts responsibility for the ${vehicle} during the rental period and agrees to return it in the same general condition, ordinary wear excepted.`,
|
||||
authorizedUse: 'Only the primary driver and approved additional drivers may operate the vehicle. The renter is responsible for any fines, tolls, traffic violations, or unauthorized use during the reservation.',
|
||||
fuel: 'The vehicle must be returned with the agreed fuel level. Any shortage may result in refueling charges.',
|
||||
damage: 'Any loss or damage, including damage caused by negligence or misuse, may be charged to the renter according to company policy and applicable law.',
|
||||
deposit: 'A security deposit may be held to secure unpaid balances, penalties, damage, or post-rental adjustments until the reservation is reconciled.',
|
||||
lateFees: 'Late return, excess mileage, cleaning, or administrative fees may be applied based on the reservation terms and final inspection.',
|
||||
paymentAuthorization: (company: string) => `The renter authorizes ${company} to process charges related to this reservation, including rental charges, taxes, optional coverages, additional drivers, and approved post-rental adjustments.`,
|
||||
general: "This agreement remains subject to the company's full rental policies, final inspection, and applicable local regulations.",
|
||||
},
|
||||
contractStatusLabels: { DRAFT: 'Draft', PENDING: 'Pending', ACTIVE: 'Active', COMPLETED: 'Completed', CANCELLED: 'Cancelled' },
|
||||
paymentStatusLabels: { UNPAID: 'Unpaid', PAID: 'Paid', PARTIAL: 'Partial', REFUNDED: 'Refunded', OVERDUE: 'Overdue', PENDING: 'Pending', COMPLETED: 'Completed', FAILED: 'Failed' },
|
||||
chargeTypeLabels: { PER_DAY: 'Per day', FLAT: 'Flat fee', FLAT_RATE: 'Flat fee', PER_RENTAL: 'Per rental' },
|
||||
damageTypeLabels: { SCRATCH: 'Scratch', DENT: 'Dent', CRACK: 'Crack', CHIP: 'Chip', MISSING: 'Missing', STAIN: 'Stain', OTHER: 'Other' },
|
||||
severityLabels: { MINOR: 'Minor', MODERATE: 'Moderate', MAJOR: 'Major' },
|
||||
fuelLevelLabels: { FULL: 'Full', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'Empty' },
|
||||
paymentProviderLabels: { CASH: 'Cash', STRIPE: 'Stripe', BANK_TRANSFER: 'Bank transfer', CHECK: 'Check', PAYPAL: 'PayPal', CREDIT_CARD: 'Credit card', DEBIT_CARD: 'Debit card' },
|
||||
},
|
||||
fr: {
|
||||
back: 'Retour aux contrats',
|
||||
booking: 'Réservation',
|
||||
print: 'Imprimer',
|
||||
generated: 'Généré',
|
||||
status: 'Statut',
|
||||
paymentStatus: 'Statut du paiement',
|
||||
paymentMode: 'Mode de paiement',
|
||||
company: 'Société de location',
|
||||
driver: 'Conducteur principal',
|
||||
additionalDrivers: 'Conducteurs supplémentaires',
|
||||
vehicle: 'Véhicule',
|
||||
period: 'Période de location',
|
||||
pickup: 'Départ',
|
||||
dropoff: 'Retour',
|
||||
start: 'Début',
|
||||
end: 'Fin',
|
||||
contractTerms: 'Clauses du contrat',
|
||||
insurance: 'Assurance',
|
||||
inspections: 'État du véhicule et dommages',
|
||||
noDamage: 'Aucun dommage enregistré au départ.',
|
||||
invoice: 'Facture',
|
||||
subtotal: 'Sous-total',
|
||||
paid: 'Payé',
|
||||
balance: 'Solde dû',
|
||||
payments: 'Paiements',
|
||||
notes: 'Notes de réservation',
|
||||
footer: 'Note de bas de page',
|
||||
none: 'Non renseigné',
|
||||
contractNo: 'Contrat #',
|
||||
invoiceNo: 'Facture #',
|
||||
fuel: 'Politique carburant',
|
||||
deposit: 'Politique de dépôt',
|
||||
lateFees: 'Politique de retard',
|
||||
damage: 'Politique dommages',
|
||||
termsLabel: 'Conditions',
|
||||
signature: 'Signature requise',
|
||||
customerDamage: 'Inspection de départ',
|
||||
mileage: 'Kilométrage',
|
||||
fuelLevel: 'Niveau de carburant',
|
||||
unitPrice: 'Prix unitaire',
|
||||
qty: 'Qté',
|
||||
total: 'Total',
|
||||
actionOpenBooking: 'Ouvrir la réservation',
|
||||
noAdditionalDrivers: 'Aucun conducteur supplémentaire.',
|
||||
noInsurance: 'Aucune assurance sélectionnée.',
|
||||
noPayments: 'Aucun paiement enregistré.',
|
||||
loading: 'Chargement du contrat…',
|
||||
yes: 'Oui',
|
||||
no: 'Non',
|
||||
agreementTitle: 'Contrat de location',
|
||||
agreementForReservation: 'Pour la réservation',
|
||||
draftAgreement: 'Brouillon de contrat',
|
||||
customerInformation: 'Informations client',
|
||||
rentalPeriodDetails: 'Période de location et conducteur',
|
||||
insuranceCondition: 'Assurance et état du véhicule',
|
||||
acknowledgementSignature: 'Reconnaissance et signature',
|
||||
vehicleInformation: 'Informations véhicule',
|
||||
rentalRate: 'Tarification de location',
|
||||
billingSummary: 'Résumé de facturation',
|
||||
damageDiagram: 'Schéma des dommages du véhicule',
|
||||
customerName: 'Nom du client',
|
||||
homeAddress: 'Adresse',
|
||||
cityCountry: 'Ville / Pays',
|
||||
telephone: 'Téléphone',
|
||||
emailLabel: 'E-mail',
|
||||
driverLicenseNumber: 'N° de permis',
|
||||
nationalityLabel: 'Nationalité',
|
||||
birthDate: 'Date de naissance',
|
||||
issuedExpires: 'Délivré / Expire',
|
||||
dateOut: 'Date de sortie',
|
||||
dateDueIn: 'Date de retour prévue',
|
||||
mileageOut: 'Kilométrage sortie',
|
||||
mileageIn: 'Kilométrage retour',
|
||||
pickupLocationLabel: 'Lieu de départ',
|
||||
returnLocationLabel: 'Lieu de retour',
|
||||
insuranceSelections: 'Assurances sélectionnées',
|
||||
checkInCondition: 'État au départ',
|
||||
signatureAcknowledgement:
|
||||
"Le locataire confirme avoir reçu le véhicule dans un état acceptable, s'engage à le restituer selon les politiques de location et accepte la responsabilité des frais autorisés, infractions, pénalités de retard, ajustements carburant et constats de dommages liés à cette réservation.",
|
||||
customerSignature: 'Signature du client',
|
||||
companyRepresentative: 'Représentant de la société',
|
||||
yearMakeModel: 'Année / Marque / Modèle',
|
||||
licensePlateLabel: "Plaque d'immatriculation",
|
||||
vehicleColor: 'Couleur du véhicule',
|
||||
categoryLabel: 'Catégorie',
|
||||
vinLabel: 'VIN',
|
||||
dailyRate: 'Tarif journalier',
|
||||
rentalDays: 'Jours de location',
|
||||
insuranceTotal: 'Total assurance',
|
||||
additionalDriverFees: 'Frais conducteur supplémentaire',
|
||||
amountPaidLabel: 'Montant payé',
|
||||
balanceDueLabel: 'Solde dû',
|
||||
lastPayment: 'Dernier paiement',
|
||||
roadsideAssistance: 'Pour une assistance routière ou un remorquage hors horaires, appelez :',
|
||||
taxesLabel: 'Taxes',
|
||||
totalChargeLabel: 'Montant total',
|
||||
damageMarkersRecorded: (count: number) => `${count} repère(s) de dommage enregistré(s) au départ.`,
|
||||
termsAndConditions: 'Conditions générales du contrat de location',
|
||||
checkInMileageFuel: 'Kilométrage / carburant départ',
|
||||
checkOutMileageFuel: 'Kilométrage / carburant retour',
|
||||
damageEntries: 'Dommages enregistrés',
|
||||
notesAndFooter: 'Notes et pied de page',
|
||||
paymentsRecorded: (count: number) => `${count} paiement(s) enregistré(s).`,
|
||||
clauseHeadings: [
|
||||
'Véhicule et réservation',
|
||||
'Usage autorisé',
|
||||
'Carburant et restitution',
|
||||
'Responsabilité dommages',
|
||||
'Dépôt de garantie',
|
||||
'Retard et frais supplémentaires',
|
||||
'Autorisation de paiement',
|
||||
'Dispositions générales',
|
||||
],
|
||||
fallbackClauses: {
|
||||
vehicleReservation: (vehicle: string) => `Le locataire accepte la responsabilité du ${vehicle} pendant la période de location et s'engage à le restituer dans le même état général, hors usure normale.`,
|
||||
authorizedUse: 'Seuls le conducteur principal et les conducteurs supplémentaires approuvés peuvent utiliser le véhicule. Le locataire reste responsable des amendes, péages, infractions routières ou usages non autorisés durant la réservation.',
|
||||
fuel: 'Le véhicule doit être restitué avec le niveau de carburant convenu. Toute insuffisance peut entraîner des frais de ravitaillement.',
|
||||
damage: 'Toute perte ou tout dommage, y compris causé par négligence ou mauvaise utilisation, peut être facturé au locataire selon la politique de la société et la réglementation applicable.',
|
||||
deposit: "Un dépôt de garantie peut être retenu pour couvrir les soldes impayés, pénalités, dommages ou ajustements post-location jusqu'à la clôture complète de la réservation.",
|
||||
lateFees: "Des frais de retard, kilométrage excédentaire, nettoyage ou gestion peuvent être appliqués selon les conditions de réservation et l'inspection finale.",
|
||||
paymentAuthorization: (company: string) => `Le locataire autorise ${company} à traiter les frais liés à cette réservation, y compris location, taxes, garanties optionnelles, conducteurs supplémentaires et ajustements post-location approuvés.`,
|
||||
general: "Le présent accord reste soumis aux politiques complètes de la société, à l'inspection finale et à la réglementation locale applicable.",
|
||||
},
|
||||
contractStatusLabels: { DRAFT: 'Brouillon', PENDING: 'En attente', ACTIVE: 'Actif', COMPLETED: 'Terminé', CANCELLED: 'Annulé' },
|
||||
paymentStatusLabels: { UNPAID: 'Non payé', PAID: 'Payé', PARTIAL: 'Partiel', REFUNDED: 'Remboursé', OVERDUE: 'En retard', PENDING: 'En attente', COMPLETED: 'Complété', FAILED: 'Échoué' },
|
||||
chargeTypeLabels: { PER_DAY: 'Par jour', FLAT: 'Forfait', FLAT_RATE: 'Forfait', PER_RENTAL: 'Par location' },
|
||||
damageTypeLabels: { SCRATCH: 'Rayure', DENT: 'Bosse', CRACK: 'Fissure', CHIP: 'Éclat', MISSING: 'Manquant', STAIN: 'Tache', OTHER: 'Autre' },
|
||||
severityLabels: { MINOR: 'Léger', MODERATE: 'Modéré', MAJOR: 'Majeur' },
|
||||
fuelLevelLabels: { FULL: 'Plein', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'Vide' },
|
||||
paymentProviderLabels: { CASH: 'Espèces', STRIPE: 'Stripe', BANK_TRANSFER: 'Virement bancaire', CHECK: 'Chèque', PAYPAL: 'PayPal', CREDIT_CARD: 'Carte crédit', DEBIT_CARD: 'Carte débit' },
|
||||
},
|
||||
ar: {
|
||||
back: 'العودة إلى العقود',
|
||||
booking: 'الحجز',
|
||||
print: 'طباعة',
|
||||
generated: 'تم الإنشاء',
|
||||
status: 'الحالة',
|
||||
paymentStatus: 'حالة الدفع',
|
||||
paymentMode: 'طريقة الدفع',
|
||||
company: 'شركة التأجير',
|
||||
driver: 'السائق الرئيسي',
|
||||
additionalDrivers: 'السائقون الإضافيون',
|
||||
vehicle: 'المركبة',
|
||||
period: 'مدة الإيجار',
|
||||
pickup: 'الاستلام',
|
||||
dropoff: 'التسليم',
|
||||
start: 'البداية',
|
||||
end: 'النهاية',
|
||||
contractTerms: 'شروط العقد',
|
||||
insurance: 'التأمين',
|
||||
inspections: 'حالة المركبة والأضرار',
|
||||
noDamage: 'لا توجد أضرار مسجلة عند الاستلام.',
|
||||
invoice: 'الفاتورة',
|
||||
subtotal: 'الإجمالي الفرعي',
|
||||
paid: 'المدفوع',
|
||||
balance: 'الرصيد المستحق',
|
||||
payments: 'المدفوعات',
|
||||
notes: 'ملاحظات الحجز',
|
||||
footer: 'ملاحظة التذييل',
|
||||
none: 'غير متوفر',
|
||||
contractNo: 'رقم العقد',
|
||||
invoiceNo: 'رقم الفاتورة',
|
||||
fuel: 'سياسة الوقود',
|
||||
deposit: 'سياسة العربون',
|
||||
lateFees: 'سياسة التأخير',
|
||||
damage: 'سياسة الأضرار',
|
||||
termsLabel: 'الشروط',
|
||||
signature: 'التوقيع مطلوب',
|
||||
customerDamage: 'فحص الاستلام',
|
||||
mileage: 'عداد المسافة',
|
||||
fuelLevel: 'مستوى الوقود',
|
||||
unitPrice: 'سعر الوحدة',
|
||||
qty: 'الكمية',
|
||||
total: 'الإجمالي',
|
||||
actionOpenBooking: 'فتح الحجز',
|
||||
noAdditionalDrivers: 'لا يوجد سائقون إضافيون.',
|
||||
noInsurance: 'لم يتم اختيار تأمين.',
|
||||
noPayments: 'لا توجد مدفوعات مسجلة.',
|
||||
loading: 'جار تحميل العقد…',
|
||||
yes: 'نعم',
|
||||
no: 'لا',
|
||||
agreementTitle: 'عقد تأجير',
|
||||
agreementForReservation: 'للحجز',
|
||||
draftAgreement: 'مسودة العقد',
|
||||
customerInformation: 'معلومات العميل',
|
||||
rentalPeriodDetails: 'مدة التأجير وبيانات السائق',
|
||||
insuranceCondition: 'التأمين وحالة المركبة',
|
||||
acknowledgementSignature: 'إقرار السائق والتوقيع',
|
||||
vehicleInformation: 'معلومات مركبة التأجير',
|
||||
rentalRate: 'سعر التأجير',
|
||||
billingSummary: 'ملخص الفوترة',
|
||||
damageDiagram: 'مخطط أضرار المركبة',
|
||||
customerName: 'اسم العميل',
|
||||
homeAddress: 'العنوان',
|
||||
cityCountry: 'المدينة / الدولة',
|
||||
telephone: 'الهاتف',
|
||||
emailLabel: 'البريد الإلكتروني',
|
||||
driverLicenseNumber: 'رقم رخصة القيادة',
|
||||
nationalityLabel: 'الجنسية',
|
||||
birthDate: 'تاريخ الميلاد',
|
||||
issuedExpires: 'الإصدار / الانتهاء',
|
||||
dateOut: 'تاريخ الخروج',
|
||||
dateDueIn: 'تاريخ الإرجاع',
|
||||
mileageOut: 'عداد الخروج',
|
||||
mileageIn: 'عداد الإرجاع',
|
||||
pickupLocationLabel: 'مكان الاستلام',
|
||||
returnLocationLabel: 'مكان التسليم',
|
||||
insuranceSelections: 'خيارات التأمين',
|
||||
checkInCondition: 'الحالة عند الاستلام',
|
||||
signatureAcknowledgement:
|
||||
'يقر المستأجر بأنه استلم المركبة بحالة مقبولة، ويلتزم بإعادتها وفق سياسات التأجير، ويقبل المسؤولية عن الرسوم المعتمدة والمخالفات ورسوم التأخير وتسويات الوقود وتقييمات الأضرار المرتبطة بهذا الحجز.',
|
||||
customerSignature: 'توقيع العميل',
|
||||
companyRepresentative: 'ممثل الشركة',
|
||||
yearMakeModel: 'السنة / الصانع / الطراز',
|
||||
licensePlateLabel: 'لوحة المركبة',
|
||||
vehicleColor: 'لون المركبة',
|
||||
categoryLabel: 'الفئة',
|
||||
vinLabel: 'رقم الهيكل',
|
||||
dailyRate: 'السعر اليومي',
|
||||
rentalDays: 'أيام التأجير',
|
||||
insuranceTotal: 'إجمالي التأمين',
|
||||
additionalDriverFees: 'رسوم السائق الإضافي',
|
||||
amountPaidLabel: 'المبلغ المدفوع',
|
||||
balanceDueLabel: 'الرصيد المستحق',
|
||||
lastPayment: 'آخر دفعة',
|
||||
roadsideAssistance: 'للمساعدة على الطريق أو السحب خارج ساعات العمل اتصل بـ:',
|
||||
taxesLabel: 'الضرائب',
|
||||
totalChargeLabel: 'إجمالي الرسوم',
|
||||
damageMarkersRecorded: (count: number) => `تم تسجيل ${count} علامة/علامات ضرر عند الاستلام.`,
|
||||
termsAndConditions: 'الشروط والأحكام لعقد التأجير',
|
||||
checkInMileageFuel: 'عداد / وقود الاستلام',
|
||||
checkOutMileageFuel: 'عداد / وقود الإرجاع',
|
||||
damageEntries: 'الأضرار المسجلة',
|
||||
notesAndFooter: 'الملاحظات والتذييل',
|
||||
paymentsRecorded: (count: number) => `تم تسجيل ${count} دفعة/دفعات.`,
|
||||
clauseHeadings: [
|
||||
'المركبة والحجز',
|
||||
'الاستخدام المصرح',
|
||||
'الوقود وحالة الإرجاع',
|
||||
'مسؤولية الأضرار',
|
||||
'وديعة الضمان',
|
||||
'التأخير والرسوم الإضافية',
|
||||
'تفويض الدفع',
|
||||
'أحكام عامة',
|
||||
],
|
||||
fallbackClauses: {
|
||||
vehicleReservation: (vehicle: string) => `يقبل المستأجر المسؤولية عن ${vehicle} طوال مدة التأجير، ويتعهد بإعادته بنفس الحالة العامة باستثناء الاستهلاك العادي.`,
|
||||
authorizedUse: 'يُسمح فقط للسائق الرئيسي والسائقين الإضافيين المعتمدين بقيادة المركبة. ويتحمل المستأجر مسؤولية أي غرامات أو رسوم طرق أو مخالفات مرورية أو استخدام غير مصرح به أثناء الحجز.',
|
||||
fuel: 'يجب إعادة المركبة بمستوى الوقود المتفق عليه. وأي نقص قد يترتب عليه رسوم تعبئة.',
|
||||
damage: 'أي فقدان أو ضرر، بما في ذلك الناتج عن الإهمال أو سوء الاستخدام، قد يتم تحميله على المستأجر وفق سياسة الشركة والأنظمة المعمول بها.',
|
||||
deposit: 'قد يتم حجز وديعة ضمان لتغطية الأرصدة غير المسددة أو الغرامات أو الأضرار أو التسويات اللاحقة إلى حين إقفال الحجز بشكل نهائي.',
|
||||
lateFees: 'قد تُفرض رسوم التأخير أو الكيلومترات الزائدة أو التنظيف أو الرسوم الإدارية وفق شروط الحجز والفحص النهائي.',
|
||||
paymentAuthorization: (company: string) => `يفوض المستأجر ${company} بتحصيل الرسوم المتعلقة بهذا الحجز، بما في ذلك قيمة التأجير والضرائب والتغطيات الاختيارية والسائقين الإضافيين والتسويات المعتمدة بعد الإرجاع.`,
|
||||
general: 'يبقى هذا الاتفاق خاضعًا لسياسات الشركة الكاملة والفحص النهائي والأنظمة المحلية المعمول بها.',
|
||||
},
|
||||
contractStatusLabels: { DRAFT: 'مسودة', PENDING: 'قيد الانتظار', ACTIVE: 'نشط', COMPLETED: 'مكتمل', CANCELLED: 'ملغى' },
|
||||
paymentStatusLabels: { UNPAID: 'غير مدفوع', PAID: 'مدفوع', PARTIAL: 'جزئي', REFUNDED: 'مسترد', OVERDUE: 'متأخر', PENDING: 'قيد الانتظار', COMPLETED: 'مكتمل', FAILED: 'فشل' },
|
||||
chargeTypeLabels: { PER_DAY: 'يومي', FLAT: 'مقطوع', FLAT_RATE: 'مقطوع', PER_RENTAL: 'لكل إيجار' },
|
||||
damageTypeLabels: { SCRATCH: 'خدش', DENT: 'دهس', CRACK: 'تشقق', CHIP: 'تقشر', MISSING: 'مفقود', STAIN: 'بقعة', OTHER: 'أخرى' },
|
||||
severityLabels: { MINOR: 'طفيف', MODERATE: 'متوسط', MAJOR: 'خطير' },
|
||||
fuelLevelLabels: { FULL: 'ممتلئ', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'فارغ' },
|
||||
paymentProviderLabels: { CASH: 'نقداً', STRIPE: 'Stripe', BANK_TRANSFER: 'تحويل بنكي', CHECK: 'شيك', PAYPAL: 'PayPal', CREDIT_CARD: 'بطاقة ائتمان', DEBIT_CARD: 'بطاقة خصم' },
|
||||
},
|
||||
}[language]
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<ContractPayload>(`/reservations/${id}/contract`)
|
||||
.then((data) => {
|
||||
setContract(data)
|
||||
setError(null)
|
||||
})
|
||||
.catch((err) => setError(err.message ?? 'Failed to load contract'))
|
||||
}, [id])
|
||||
|
||||
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
|
||||
const formatDateTime = (value: string | null | undefined) =>
|
||||
value
|
||||
? new Date(value).toLocaleString(localeCode, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: copy.none
|
||||
|
||||
const address = useMemo(() => (
|
||||
contract ? formatAddress(contract.company.address, contract.company.city, contract.company.country) : ''
|
||||
), [contract])
|
||||
|
||||
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
|
||||
if (!contract) return <div className="card p-6 text-sm text-slate-500">{copy.loading}</div>
|
||||
|
||||
const tl = (map: Record<string, string>, key: string) => map[key] ?? key
|
||||
|
||||
const damagePoints = contract.inspections.checkIn?.damagePoints ?? []
|
||||
const rentalLine = contract.invoice.lineItems.find((item) => item.category === 'RENTAL') ?? contract.invoice.lineItems[0]
|
||||
const depositLine = contract.invoice.lineItems.find((item) => item.category === 'DEPOSIT')
|
||||
const additionalDriverLine = contract.invoice.lineItems.filter((item) => item.category === 'ADDITIONAL_DRIVER')
|
||||
const clauses = splitTerms(contract.terms.terms)
|
||||
const vehicleLabel = `${contract.vehicle.year} ${contract.vehicle.make} ${contract.vehicle.model}`
|
||||
const fallbackClauses = [
|
||||
copy.fallbackClauses.vehicleReservation(vehicleLabel),
|
||||
copy.fallbackClauses.authorizedUse,
|
||||
copy.fallbackClauses.fuel,
|
||||
copy.fallbackClauses.damage,
|
||||
copy.fallbackClauses.deposit,
|
||||
copy.fallbackClauses.lateFees,
|
||||
copy.fallbackClauses.paymentAuthorization(contract.company.name),
|
||||
copy.fallbackClauses.general,
|
||||
]
|
||||
const agreementClauses = clauses.length > 0 ? clauses : fallbackClauses
|
||||
const roadsidePhone = contract.company.phone ?? copy.none
|
||||
const latestPayment = contract.invoice.payments.at(-1)
|
||||
const driverFullName = `${contract.driver.firstName} ${contract.driver.lastName}`
|
||||
const additionalDriverNames = contract.additionalDrivers.length > 0
|
||||
? contract.additionalDrivers.map((driver) => `${driver.firstName} ${driver.lastName}`).join(', ')
|
||||
: copy.noAdditionalDrivers
|
||||
|
||||
return (
|
||||
<div className="space-y-6 print:space-y-0">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between print:hidden">
|
||||
<div className="space-y-2">
|
||||
<Link href="/dashboard/contracts" className="text-sm font-semibold text-blue-700 hover:underline">{copy.back}</Link>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-slate-500">{contract.company.name}</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="text-xl font-semibold text-slate-900">{contract.contractNumber ?? copy.contractNo}</h2>
|
||||
<span className="badge-blue">{tl(copy.contractStatusLabels, contract.status)}</span>
|
||||
<span className="badge-gray">{tl(copy.paymentStatusLabels, contract.paymentStatus)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
{copy.invoiceNo}: {contract.invoiceNumber ?? '—'} · {copy.generated}: {formatDateTime(contract.generatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 print:hidden">
|
||||
<Link href={`/dashboard/reservations/${contract.reservationId}`} className="btn-secondary">
|
||||
{copy.actionOpenBooking}
|
||||
</Link>
|
||||
<button type="button" className="btn-primary" onClick={() => window.print()}>
|
||||
{copy.print}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8 print:space-y-0">
|
||||
<section className="mx-auto max-w-[1060px] overflow-hidden rounded-[28px] border-4 border-stone-900 bg-white text-stone-900 shadow-2xl print:max-w-none print:shadow-none print:break-after-page">
|
||||
<div className="border-b border-stone-500 px-6 py-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||
{contract.company.logoUrl && (
|
||||
<div className="flex shrink-0 items-center justify-center">
|
||||
<img
|
||||
src={contract.company.logoUrl}
|
||||
alt={contract.company.name}
|
||||
className="h-24 w-24 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-stone-500">
|
||||
{contract.company.name}
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-black tracking-tight">
|
||||
{contract.company.name} {copy.agreementTitle}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm font-medium text-stone-600">
|
||||
{copy.agreementForReservation} {contract.contractNumber ?? contract.reservationId.slice(-8).toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid min-w-[220px] grid-cols-2 gap-px self-stretch overflow-hidden rounded-2xl border border-stone-400 bg-stone-300 text-sm">
|
||||
<div className="bg-white px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{copy.status}</p>
|
||||
<p className="mt-1 font-semibold">{tl(copy.contractStatusLabels, contract.status)}</p>
|
||||
</div>
|
||||
<div className="bg-white px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{copy.paymentStatus}</p>
|
||||
<p className="mt-1 font-semibold">{tl(copy.paymentStatusLabels, contract.paymentStatus)}</p>
|
||||
</div>
|
||||
<div className="bg-white px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{copy.contractNo}</p>
|
||||
<p className="mt-1 font-semibold">{contract.contractNumber ?? '—'}</p>
|
||||
</div>
|
||||
<div className="bg-white px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{copy.generated}</p>
|
||||
<p className="mt-1 font-semibold">{formatDateTime(contract.generatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 p-6 lg:grid-cols-[1.6fr_1fr] print:grid-cols-[1.6fr_1fr]">
|
||||
<div className="space-y-5">
|
||||
<PaperSection title={copy.customerInformation}>
|
||||
<div className="grid gap-px bg-stone-300 sm:grid-cols-2">
|
||||
<PaperField label={copy.customerName} value={driverFullName} className="bg-white sm:col-span-2" />
|
||||
<PaperField label={copy.homeAddress} value={address || copy.none} className="bg-white sm:col-span-2" />
|
||||
<PaperField label={copy.cityCountry} value={[contract.company.city, contract.company.country].filter(Boolean).join(', ') || copy.none} className="bg-white" />
|
||||
<PaperField label={copy.telephone} value={contract.driver.phone ?? copy.none} className="bg-white" />
|
||||
<PaperField label={copy.emailLabel} value={contract.driver.email} className="bg-white sm:col-span-2" />
|
||||
<PaperField label={copy.driverLicenseNumber} value={contract.driver.driverLicense ?? copy.none} className="bg-white" />
|
||||
<PaperField label={copy.nationalityLabel} value={contract.driver.nationality ?? copy.none} className="bg-white" />
|
||||
<PaperField label={copy.birthDate} value={formatDateOnly(contract.driver.dateOfBirth, localeCode, copy.none)} className="bg-white" />
|
||||
<PaperField label={copy.issuedExpires} value={`${formatDateOnly(contract.driver.licenseIssuedAt, localeCode, copy.none)} — ${formatDateOnly(contract.driver.licenseExpiry, localeCode, copy.none)}`} className="bg-white" />
|
||||
</div>
|
||||
</PaperSection>
|
||||
|
||||
<PaperSection title={copy.rentalPeriodDetails}>
|
||||
<div className="grid gap-px bg-stone-300 sm:grid-cols-2">
|
||||
<PaperField label={copy.dateOut} value={formatDateTime(contract.rentalPeriod.startDate)} className="bg-white" />
|
||||
<PaperField label={copy.dateDueIn} value={formatDateTime(contract.rentalPeriod.endDate)} className="bg-white" />
|
||||
<PaperField label={copy.mileageOut} value={String(contract.inspections.checkIn?.mileage ?? copy.none)} className="bg-white" />
|
||||
<PaperField label={copy.mileageIn} value={String(contract.inspections.checkOut?.mileage ?? copy.none)} className="bg-white" />
|
||||
<PaperField label={copy.pickupLocationLabel} value={contract.rentalPeriod.pickupLocation ?? copy.none} className="bg-white" />
|
||||
<PaperField label={copy.returnLocationLabel} value={contract.rentalPeriod.returnLocation ?? copy.none} className="bg-white" />
|
||||
<PaperField label={copy.additionalDrivers} value={additionalDriverNames} className="bg-white sm:col-span-2" />
|
||||
</div>
|
||||
</PaperSection>
|
||||
|
||||
<PaperSection title={copy.insuranceCondition}>
|
||||
<div className="grid gap-px bg-stone-300">
|
||||
<PaperField
|
||||
label={copy.insuranceSelections}
|
||||
value={contract.insurance.policies.length > 0 ? contract.insurance.policies.map((policy) => `${policy.name} (${tl(copy.chargeTypeLabels, policy.chargeType)})`).join(', ') : copy.noInsurance}
|
||||
className="bg-white"
|
||||
/>
|
||||
<PaperField label={copy.fuel} value={contract.terms.fuelPolicy ?? copy.none} className="bg-white" />
|
||||
<PaperField label={copy.damage} value={contract.terms.damagePolicy ?? copy.none} className="bg-white" />
|
||||
<PaperField
|
||||
label={copy.checkInCondition}
|
||||
value={contract.inspections.checkIn?.generalCondition ?? contract.inspections.checkIn?.employeeNotes ?? copy.none}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
</PaperSection>
|
||||
|
||||
<PaperSection title={copy.acknowledgementSignature}>
|
||||
<div className="space-y-4 bg-white p-4 text-sm leading-6 text-stone-700">
|
||||
<p>{copy.signatureAcknowledgement}</p>
|
||||
<p>
|
||||
{copy.signature}: <span className="font-semibold text-stone-900">{contract.terms.signatureRequired ? copy.yes : copy.no}</span>
|
||||
</p>
|
||||
<div className="grid gap-4 pt-4 sm:grid-cols-2">
|
||||
<div className="border-t border-dashed border-stone-500 pt-2 text-sm font-medium text-stone-800">
|
||||
{copy.customerSignature}
|
||||
</div>
|
||||
<div className="border-t border-dashed border-stone-500 pt-2 text-sm font-medium text-stone-800">
|
||||
{copy.companyRepresentative}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PaperSection>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<PaperSection title={copy.vehicleInformation}>
|
||||
<div className="grid gap-px bg-stone-300">
|
||||
<PaperField label={copy.yearMakeModel} value={`${contract.vehicle.year} ${contract.vehicle.make} ${contract.vehicle.model}`} className="bg-white" />
|
||||
<PaperField label={copy.licensePlateLabel} value={contract.vehicle.licensePlate} className="bg-white" />
|
||||
<PaperField label={copy.vehicleColor} value={contract.vehicle.color} className="bg-white" />
|
||||
<PaperField label={copy.categoryLabel} value={contract.vehicle.category} className="bg-white" />
|
||||
<PaperField label={copy.vinLabel} value={contract.vehicle.vin ?? copy.none} className="bg-white" />
|
||||
<div className="border border-stone-400/80 bg-white p-3">
|
||||
<p className="mb-3 text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{copy.damageDiagram}</p>
|
||||
<div className="flex justify-center">
|
||||
<VehicleDamageDiagram points={damagePoints} />
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-stone-600">
|
||||
{damagePoints.length > 0 ? copy.damageMarkersRecorded(damagePoints.length) : copy.noDamage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PaperSection>
|
||||
|
||||
<PaperSection title={copy.rentalRate}>
|
||||
<div className="grid gap-px bg-stone-300">
|
||||
<PaperField label={copy.dailyRate} value={rentalLine ? formatCurrency(rentalLine.unitPrice, contract.invoice.currency) : copy.none} className="bg-white" />
|
||||
<PaperField label={copy.rentalDays} value={String(contract.rentalPeriod.totalDays)} className="bg-white" />
|
||||
<PaperField label={copy.insuranceTotal} value={formatCurrency(contract.insurance.total, contract.invoice.currency)} className="bg-white" />
|
||||
<PaperField label={copy.additionalDriverFees} value={formatCurrency(additionalDriverLine.reduce((sum, item) => sum + item.total, 0), contract.invoice.currency)} className="bg-white" />
|
||||
<PaperField label={copy.deposit} value={depositLine ? formatCurrency(depositLine.total, contract.invoice.currency) : copy.none} className="bg-white" />
|
||||
<PaperField label={copy.subtotal} value={formatCurrency(contract.invoice.subtotal, contract.invoice.currency)} className="bg-white" />
|
||||
<PaperField label={copy.taxesLabel} value={contract.invoice.taxes.length > 0 ? contract.invoice.taxes.map((tax) => `${tax.label} ${tax.rate}%`).join(', ') : copy.none} className="bg-white" />
|
||||
<PaperField label={copy.totalChargeLabel} value={formatCurrency(contract.invoice.total, contract.invoice.currency)} className="bg-white" />
|
||||
<PaperField label={copy.amountPaidLabel} value={formatCurrency(contract.invoice.amountPaid, contract.invoice.currency)} className="bg-white" />
|
||||
<PaperField label={copy.balanceDueLabel} value={formatCurrency(contract.invoice.balanceDue, contract.invoice.currency)} className="bg-white" />
|
||||
<PaperField label={copy.paymentMode} value={contract.paymentMode ?? latestPayment?.paymentMethod ?? copy.none} className="bg-white" />
|
||||
</div>
|
||||
</PaperSection>
|
||||
|
||||
<PaperSection title={copy.billingSummary}>
|
||||
<div className="space-y-3 bg-white p-4 text-sm text-stone-700">
|
||||
{contract.invoice.lineItems.map((item, index) => (
|
||||
<div key={`${item.description}-${index}`} className="flex items-start justify-between gap-3 border-b border-stone-200 pb-2 last:border-b-0">
|
||||
<div>
|
||||
<p className="font-medium text-stone-900">{item.description}</p>
|
||||
<p className="text-xs text-stone-500">{copy.qty} {item.qty} · {formatCurrency(item.unitPrice, contract.invoice.currency)}</p>
|
||||
</div>
|
||||
<p className="font-semibold text-stone-900">{formatCurrency(item.total, contract.invoice.currency)}</p>
|
||||
</div>
|
||||
))}
|
||||
{latestPayment ? (
|
||||
<div className="rounded-xl border border-stone-300 bg-stone-50 px-3 py-2 text-xs text-stone-600">
|
||||
{copy.lastPayment}: {tl(copy.paymentProviderLabels, latestPayment.provider)} · {tl(copy.paymentStatusLabels, latestPayment.status)} · {formatDateTime(latestPayment.paidAt ?? latestPayment.createdAt)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PaperSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-stone-500 px-6 py-4 text-center text-sm text-stone-700">
|
||||
{copy.roadsideAssistance} <span className="font-semibold text-stone-900">{roadsidePhone}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-[1060px] rounded-[28px] border-4 border-stone-900 bg-white px-8 py-7 text-stone-900 shadow-2xl print:max-w-none print:shadow-none">
|
||||
<div className="border-b border-stone-400 pb-4 text-center">
|
||||
<h2 className="text-2xl font-black tracking-tight">{copy.termsAndConditions}</h2>
|
||||
<p className="mt-1 text-sm text-stone-600">
|
||||
{contract.company.name} · {contract.contractNumber ?? copy.draftAgreement}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 md:grid-cols-2 print:grid-cols-2">
|
||||
{agreementClauses.map((clause, index) => (
|
||||
<div key={`${index}-${clause.slice(0, 24)}`} className="text-sm leading-6 text-stone-700">
|
||||
<p className="font-bold text-stone-900">
|
||||
{index + 1}. {copy.clauseHeadings[index] ?? copy.clauseHeadings[copy.clauseHeadings.length - 1]}
|
||||
</p>
|
||||
<p className="mt-2 whitespace-pre-wrap">{clause}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-5 lg:grid-cols-2 print:grid-cols-2">
|
||||
<PaperSection title={copy.inspections}>
|
||||
<div className="grid gap-px bg-stone-300">
|
||||
<PaperField label={copy.checkInMileageFuel} value={`${contract.inspections.checkIn?.mileage ?? copy.none} / ${contract.inspections.checkIn?.fuelLevel ? tl(copy.fuelLevelLabels, contract.inspections.checkIn.fuelLevel) : copy.none}`} className="bg-white" />
|
||||
<PaperField label={copy.checkOutMileageFuel} value={`${contract.inspections.checkOut?.mileage ?? copy.none} / ${contract.inspections.checkOut?.fuelLevel ? tl(copy.fuelLevelLabels, contract.inspections.checkOut.fuelLevel) : copy.none}`} className="bg-white" />
|
||||
<PaperField label={copy.damageEntries} value={damagePoints.length > 0 ? damagePoints.map((point) => `${tl(copy.damageTypeLabels, point.damageType)} (${tl(copy.severityLabels, point.severity)})`).join(', ') : copy.noDamage} className="bg-white" />
|
||||
</div>
|
||||
</PaperSection>
|
||||
|
||||
<PaperSection title={copy.notesAndFooter}>
|
||||
<div className="space-y-3 bg-white p-4 text-sm text-stone-700">
|
||||
<div>
|
||||
<p className="font-semibold text-stone-900">{copy.notes}</p>
|
||||
<p className="mt-1 whitespace-pre-wrap">{contract.notes || copy.none}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-stone-900">{copy.footer}</p>
|
||||
<p className="mt-1 whitespace-pre-wrap">{contract.terms.footerNote || copy.none}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-stone-900">{copy.payments}</p>
|
||||
<p className="mt-1">{contract.invoice.payments.length > 0 ? copy.paymentsRecorded(contract.invoice.payments.length) : copy.noPayments}</p>
|
||||
</div>
|
||||
</div>
|
||||
</PaperSection>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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={`/dashboard/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>
|
||||
)
|
||||
}
|
||||
@@ -6,92 +6,789 @@ import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
interface OfferRow {
|
||||
const CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const
|
||||
type Category = typeof CATEGORIES[number]
|
||||
|
||||
interface Offer {
|
||||
id: string
|
||||
title: string
|
||||
type: 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
|
||||
promoCode: string | null
|
||||
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']): string {
|
||||
if (offer.type === 'PERCENTAGE') return `${offer.discountValue}%`
|
||||
if (offer.type === 'FIXED_AMOUNT') return formatCurrency(offer.discountValue, 'MAD')
|
||||
if (offer.type === 'FREE_DAY') return t.freeDay
|
||||
if (offer.type === 'SPECIAL_RATE') return `${offer.specialRate ?? offer.discountValue} MAD/day`
|
||||
return String(offer.discountValue)
|
||||
}
|
||||
|
||||
export default function OffersPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const [rows, setRows] = useState<OfferRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Offers',
|
||||
subtitle: 'Promotions shown on your site and optionally on the marketplace.',
|
||||
ends: 'ends',
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
marketplace: 'Marketplace',
|
||||
featured: 'Featured',
|
||||
empty: 'No offers created yet.',
|
||||
},
|
||||
fr: {
|
||||
title: 'Offres',
|
||||
subtitle: 'Promotions affichées sur votre site et éventuellement sur la marketplace.',
|
||||
ends: 'expire le',
|
||||
active: 'Actif',
|
||||
inactive: 'Inactif',
|
||||
marketplace: 'Marketplace',
|
||||
featured: 'Mise en avant',
|
||||
empty: 'Aucune offre créée pour le moment.',
|
||||
},
|
||||
ar: {
|
||||
title: 'العروض',
|
||||
subtitle: 'عروض ترويجية تظهر في موقعك ويمكن عرضها أيضاً في السوق.',
|
||||
ends: 'تنتهي في',
|
||||
active: 'نشط',
|
||||
inactive: 'غير نشط',
|
||||
marketplace: 'السوق',
|
||||
featured: 'مميز',
|
||||
empty: 'لا توجد عروض حتى الآن.',
|
||||
},
|
||||
}[language]
|
||||
const t = copy[language]
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<OfferRow[]>('/offers')
|
||||
.then(setRows)
|
||||
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(data ?? []))
|
||||
.catch((err) => setError(err.message))
|
||||
}, [])
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function loadFleet() {
|
||||
setLoadingFleet(true)
|
||||
try {
|
||||
const data = await apiFetch<FleetVehicle[]>('/vehicles?pageSize=200')
|
||||
setFleet(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: 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">
|
||||
<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>
|
||||
{/* 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">
|
||||
{rows.map((offer) => (
|
||||
<div key={offer.id} className="card p-5">
|
||||
{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>
|
||||
<h3 className="text-base font-semibold text-slate-900">{offer.title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">{offer.type} · {copy.ends} {dayjs(offer.validUntil).format('MMM D, YYYY')}</p>
|
||||
<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={offer.isActive ? 'badge-green' : 'badge-gray'}>{offer.isActive ? copy.active : copy.inactive}</span>
|
||||
<span className={`shrink-0 ${offer.isActive ? 'badge-green' : 'badge-gray'}`}>
|
||||
{offer.isActive ? t.active : t.inactive}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{offer.isPublic && <span className="badge-blue">{copy.marketplace}</span>}
|
||||
{offer.isFeatured && <span className="badge-purple">{copy.featured}</span>}
|
||||
|
||||
<p className="text-2xl font-bold text-slate-900">{discountLabel(offer, t)}</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>
|
||||
<p className="mt-4 text-2xl font-bold text-slate-900">
|
||||
{offer.type === 'PERCENTAGE' ? `${offer.discountValue}%` : formatCurrency(offer.discountValue, 'MAD')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 && !error && (
|
||||
<div className="card p-8 text-sm text-slate-400">{copy.empty}</div>
|
||||
|
||||
{offers.length === 0 && !error && (
|
||||
<div className="col-span-full card p-10 text-center text-sm text-slate-400">{t.empty}</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
|
||||
|
||||
{/* Create / Edit Modal */}
|
||||
{modalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/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-black/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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,7 +81,6 @@ export default function OnlineReservationsPage() {
|
||||
pendingApproval: 'Pending approval',
|
||||
loading: 'Loading…',
|
||||
allCaughtUp: 'All caught up — no pending requests.',
|
||||
history: 'History',
|
||||
customer: 'Customer',
|
||||
vehicle: 'Vehicle',
|
||||
dates: 'Dates',
|
||||
@@ -95,7 +94,6 @@ export default function OnlineReservationsPage() {
|
||||
pendingApproval: 'En attente de validation',
|
||||
loading: 'Chargement…',
|
||||
allCaughtUp: 'Tout est à jour, aucune demande en attente.',
|
||||
history: 'Historique',
|
||||
customer: 'Client',
|
||||
vehicle: 'Véhicule',
|
||||
dates: 'Dates',
|
||||
@@ -109,7 +107,6 @@ export default function OnlineReservationsPage() {
|
||||
pendingApproval: 'بانتظار الموافقة',
|
||||
loading: 'جارٍ التحميل…',
|
||||
allCaughtUp: 'لا توجد طلبات معلقة حالياً.',
|
||||
history: 'السجل',
|
||||
customer: 'العميل',
|
||||
vehicle: 'المركبة',
|
||||
dates: 'التواريخ',
|
||||
@@ -127,14 +124,9 @@ export default function OnlineReservationsPage() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await apiFetch<OnlineReservation[]>('/reservations?source=MARKETPLACE&pageSize=100')
|
||||
const result = await apiFetch<OnlineReservation[]>('/reservations?source=MARKETPLACE&status=DRAFT&pageSize=100')
|
||||
setRows(
|
||||
(result ?? []).sort((a, b) => {
|
||||
// DRAFT first, then by createdAt desc
|
||||
if (a.status === 'DRAFT' && b.status !== 'DRAFT') return -1
|
||||
if (a.status !== 'DRAFT' && b.status === 'DRAFT') return 1
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
})
|
||||
(result ?? []).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
)
|
||||
setError(null)
|
||||
} catch (err: any) {
|
||||
@@ -173,8 +165,7 @@ export default function OnlineReservationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const pending = rows.filter((r) => r.status === 'DRAFT')
|
||||
const history = rows.filter((r) => r.status !== 'DRAFT')
|
||||
const pending = rows
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -235,58 +226,6 @@ export default function OnlineReservationsPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* History section */}
|
||||
{!loading && history.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||
{copy.history}
|
||||
</h3>
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 bg-slate-50">
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.customer}</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.dates}</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.status}</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
|
||||
<th className="px-5 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{history.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-slate-50">
|
||||
<td className="px-5 py-3.5">
|
||||
<p className="text-sm font-medium text-slate-900">{r.customer.firstName} {r.customer.lastName}</p>
|
||||
<p className="text-xs text-slate-500">{r.customer.email}</p>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-sm text-slate-700">{r.vehicle.year} {r.vehicle.make} {r.vehicle.model}</td>
|
||||
<td className="px-5 py-3.5 text-sm text-slate-500">
|
||||
{dayjs(r.startDate).format('MMM D')} – {dayjs(r.endDate).format('MMM D, YYYY')}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold ${STATUS_STYLE[r.status] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||
{STATUS_LABEL[r.status] ?? r.status}
|
||||
</span>
|
||||
{r.cancelReason && (
|
||||
<p className="mt-0.5 text-xs text-slate-400 truncate max-w-[160px]" title={r.cancelReason}>{r.cancelReason}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right text-sm font-semibold text-slate-900">
|
||||
{formatCurrency(r.totalAmount, 'MAD')}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<Link href={`/dashboard/reservations/${r.id}`} className="text-slate-400 hover:text-slate-700">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ function SubscriptionBanner({
|
||||
<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>
|
||||
<a href="/dashboard/billing" className="ml-auto text-sm font-semibold underline hover:no-underline">
|
||||
<a href="/dashboard/subscription" className="ml-auto text-sm font-semibold underline hover:no-underline">
|
||||
{manageBillingLabel}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
@@ -127,6 +128,9 @@ export default function ReservationDetailPage() {
|
||||
<p className="mt-1 text-sm text-slate-500">{reservation.source} · {reservation.status} · {reservation.paymentStatus}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={`/dashboard/contracts/${reservation.id}`} className="btn-secondary">
|
||||
{language === 'fr' ? 'Contrat' : language === 'ar' ? 'العقد' : 'Contract'}
|
||||
</Link>
|
||||
{reservation.status === 'DRAFT' && (
|
||||
<button disabled={acting} onClick={() => runAction('confirm')} className="btn-primary">
|
||||
{acting ? r.working : r.confirmReservation}
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
type Customer = {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
driverLicense?: string | null
|
||||
licenseExpiry?: string | null
|
||||
licenseIssuedAt?: string | null
|
||||
licenseCountry?: string | null
|
||||
licenseCategory?: string | null
|
||||
}
|
||||
type Vehicle = { id: string; make: string; model: string; licensePlate: string; status: string }
|
||||
type CreatedCustomer = { id: string; firstName: string; lastName: string; email: string }
|
||||
type AdditionalDriverForm = {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string
|
||||
driverLicense: string
|
||||
licenseExpiry: string
|
||||
licenseIssuedAt: string
|
||||
dateOfBirth: string
|
||||
nationality: string
|
||||
}
|
||||
|
||||
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('')
|
||||
const [newCustomerLastName, setNewCustomerLastName] = useState('')
|
||||
const [newCustomerEmail, setNewCustomerEmail] = useState('')
|
||||
const [newCustomerPhone, setNewCustomerPhone] = useState('')
|
||||
const [driverLicense, setDriverLicense] = useState('')
|
||||
const [licenseExpiry, setLicenseExpiry] = useState('')
|
||||
const [licenseIssuedAt, setLicenseIssuedAt] = useState('')
|
||||
const [licenseCountry, setLicenseCountry] = useState('')
|
||||
const [licenseCategory, setLicenseCategory] = useState('')
|
||||
const [additionalDriver, setAdditionalDriver] = useState<AdditionalDriverForm>({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
driverLicense: '',
|
||||
licenseExpiry: '',
|
||||
licenseIssuedAt: '',
|
||||
dateOfBirth: '',
|
||||
nationality: '',
|
||||
})
|
||||
|
||||
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',
|
||||
driverLicenseInfo: 'Driver license information',
|
||||
driverLicense: 'Driver license number',
|
||||
licenseExpiry: 'License expiry',
|
||||
licenseIssuedAt: 'License issued at',
|
||||
licenseCountry: 'License country',
|
||||
licenseCategory: 'License category',
|
||||
addAdditionalDriver: 'Add additional driver',
|
||||
additionalDriverInfo: 'Additional driver',
|
||||
dateOfBirth: 'Date of birth',
|
||||
nationality: 'License country',
|
||||
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 (optional)',
|
||||
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',
|
||||
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',
|
||||
addAdditionalDriver: 'Ajouter un conducteur supplémentaire',
|
||||
additionalDriverInfo: 'Conducteur supplémentaire',
|
||||
dateOfBirth: 'Date de naissance',
|
||||
nationality: 'Pays du permis',
|
||||
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 (optionnel)',
|
||||
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: 'طريقة الدفع',
|
||||
driverLicenseInfo: 'معلومات رخصة القيادة',
|
||||
driverLicense: 'رقم رخصة القيادة',
|
||||
licenseExpiry: 'تاريخ انتهاء الرخصة',
|
||||
licenseIssuedAt: 'تاريخ إصدار الرخصة',
|
||||
licenseCountry: 'بلد الرخصة',
|
||||
licenseCategory: 'فئة الرخصة',
|
||||
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
|
||||
|
||||
useEffect(() => {
|
||||
const selected = customers.find((customer) => customer.id === customerId)
|
||||
if (!selected) return
|
||||
setDriverLicense(selected.driverLicense ?? '')
|
||||
setLicenseExpiry(selected.licenseExpiry ? selected.licenseExpiry.slice(0, 10) : '')
|
||||
setLicenseIssuedAt(selected.licenseIssuedAt ? selected.licenseIssuedAt.slice(0, 10) : '')
|
||||
setLicenseCountry(selected.licenseCountry ?? '')
|
||||
setLicenseCategory(selected.licenseCategory ?? '')
|
||||
}, [customerId, customers])
|
||||
|
||||
async function addCustomer() {
|
||||
setError(null)
|
||||
if (!newCustomerFirstName.trim() || !newCustomerLastName.trim() || !newCustomerEmail.trim()) {
|
||||
setError(copy.required)
|
||||
return
|
||||
}
|
||||
setSavingCustomer(true)
|
||||
try {
|
||||
const created = await apiFetch<CreatedCustomer>('/customers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
firstName: newCustomerFirstName.trim(),
|
||||
lastName: newCustomerLastName.trim(),
|
||||
email: newCustomerEmail.trim(),
|
||||
phone: newCustomerPhone.trim() || undefined,
|
||||
driverLicense: driverLicense.trim() || undefined,
|
||||
licenseExpiry: licenseExpiry ? new Date(licenseExpiry).toISOString() : undefined,
|
||||
licenseIssuedAt: licenseIssuedAt ? new Date(licenseIssuedAt).toISOString() : undefined,
|
||||
licenseCountry: licenseCountry.trim() || undefined,
|
||||
licenseCategory: licenseCategory.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
setCustomers((prev) => [created, ...prev])
|
||||
setCustomerId(created.id)
|
||||
setCustomerSearch(`${created.firstName} ${created.lastName}`)
|
||||
setShowAddCustomer(false)
|
||||
setNewCustomerFirstName('')
|
||||
setNewCustomerLastName('')
|
||||
setNewCustomerEmail('')
|
||||
setNewCustomerPhone('')
|
||||
} 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 (includeAdditionalDriver && (!additionalDriver.firstName.trim() || !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() || undefined,
|
||||
licenseExpiry: licenseExpiry ? new Date(licenseExpiry).toISOString() : undefined,
|
||||
licenseIssuedAt: licenseIssuedAt ? new Date(licenseIssuedAt).toISOString() : undefined,
|
||||
licenseCountry: licenseCountry.trim() || undefined,
|
||||
licenseCategory: licenseCategory.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
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: additionalDriver.firstName.trim(),
|
||||
lastName: additionalDriver.lastName.trim(),
|
||||
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: additionalDriver.nationality.trim() || undefined,
|
||||
}] : [],
|
||||
notes: notes || undefined,
|
||||
}),
|
||||
})
|
||||
router.push(`/dashboard/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>
|
||||
<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>
|
||||
<input value={newCustomerFirstName} onChange={(e) => setNewCustomerFirstName(e.target.value)} placeholder={copy.firstName} className="input-field" />
|
||||
<input value={newCustomerLastName} onChange={(e) => setNewCustomerLastName(e.target.value)} placeholder={copy.lastName} className="input-field" />
|
||||
<input value={newCustomerEmail} onChange={(e) => setNewCustomerEmail(e.target.value)} placeholder={copy.email} className="input-field" />
|
||||
<input value={newCustomerPhone} onChange={(e) => setNewCustomerPhone(e.target.value)} placeholder={copy.phone} className="input-field" />
|
||||
<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>
|
||||
<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-amber-700">{copy.noVehicles}</span>
|
||||
) : availableVehicles.length === 0 ? (
|
||||
<span className="text-xs text-amber-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.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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<input value={licenseCategory} onChange={(e) => setLicenseCategory(e.target.value)} className="input-field" />
|
||||
</label>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<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.firstName}</span>
|
||||
<input
|
||||
value={additionalDriver.firstName}
|
||||
onChange={(e) => setAdditionalDriver((current) => ({ ...current, firstName: e.target.value }))}
|
||||
className="input-field"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.lastName}</span>
|
||||
<input
|
||||
value={additionalDriver.lastName}
|
||||
onChange={(e) => setAdditionalDriver((current) => ({ ...current, lastName: 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.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>
|
||||
<input
|
||||
value={additionalDriver.driverLicense}
|
||||
onChange={(e) => setAdditionalDriver((current) => ({ ...current, driverLicense: e.target.value }))}
|
||||
className="input-field"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">{copy.nationality}</span>
|
||||
<input
|
||||
value={additionalDriver.nationality}
|
||||
onChange={(e) => setAdditionalDriver((current) => ({ ...current, nationality: 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.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('/dashboard/reservations')}>
|
||||
{copy.cancel}
|
||||
</button>
|
||||
<button type="button" className="btn-primary" onClick={submit} disabled={saving || !canSubmit}>
|
||||
{saving ? copy.creating : copy.create}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { formatCurrency, type ApiPaginated } from '@rentaldrivego/types'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ReservationRow {
|
||||
endDate: string
|
||||
totalAmount: number
|
||||
totalDays: number
|
||||
contractNumber?: string | null
|
||||
vehicle: { make: string; model: string }
|
||||
customer: { firstName: string; lastName: string; email: string }
|
||||
}
|
||||
@@ -27,8 +28,8 @@ export default function ReservationsPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<ApiPaginated<ReservationRow>>('/reservations?pageSize=100')
|
||||
.then((result) => setRows(result.data ?? []))
|
||||
apiFetch<ReservationRow[]>('/reservations?pageSize=100')
|
||||
.then((result) => setRows(result ?? []))
|
||||
.catch((err) => setError(err.message))
|
||||
}, [])
|
||||
|
||||
@@ -39,9 +40,14 @@ export default function ReservationsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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 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="/dashboard/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 ? (
|
||||
@@ -67,6 +73,11 @@ export default function ReservationsPage() {
|
||||
{row.customer.firstName} {row.customer.lastName}
|
||||
</Link>
|
||||
<p className="text-xs text-slate-500">{row.customer.email}</p>
|
||||
<Link href={`/dashboard/contracts/${row.id}`} className="mt-1 inline-block text-xs font-semibold text-blue-700 hover:underline">
|
||||
{row.contractNumber
|
||||
? (language === 'fr' ? 'Voir le contrat' : language === 'ar' ? 'عرض العقد' : 'View contract')
|
||||
: (language === 'fr' ? 'Générer le contrat' : language === 'ar' ? 'إنشاء العقد' : 'Generate contract')}
|
||||
</Link>
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
|
||||
type Currency = 'MAD' | 'USD' | 'EUR'
|
||||
|
||||
interface Subscription {
|
||||
id: string
|
||||
plan: Plan
|
||||
billingPeriod: BillingPeriod
|
||||
status: string
|
||||
currency: Currency
|
||||
trialEndAt: string | null
|
||||
currentPeriodEnd: string | null
|
||||
cancelAtPeriodEnd: boolean
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
id: string
|
||||
amount: number
|
||||
currency: Currency
|
||||
status: string
|
||||
paymentProvider: string
|
||||
paidAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface ProviderAvailability {
|
||||
amanpay: boolean
|
||||
paypal: boolean
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
TRIALING: 'bg-sky-100 text-sky-700',
|
||||
ACTIVE: 'bg-green-100 text-green-700',
|
||||
PAST_DUE: 'bg-amber-100 text-amber-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-amber-100 text-amber-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 { 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 [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
|
||||
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
|
||||
const [currency, setCurrency] = useState<Currency>('MAD')
|
||||
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
|
||||
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>({ amanpay: false, paypal: false })
|
||||
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(() => {
|
||||
Promise.all([
|
||||
apiFetch<Subscription | null>('/subscriptions/me'),
|
||||
apiFetch<Invoice[]>('/subscriptions/invoices'),
|
||||
apiFetch<ProviderAvailability>('/subscriptions/providers'),
|
||||
])
|
||||
.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)
|
||||
setCurrency(sub.currency as Currency)
|
||||
}
|
||||
setInvoices(inv ?? [])
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
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 base = window.location.origin
|
||||
const result = await apiFetch<{ checkoutUrl: string }>('/subscriptions/checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
plan: selectedPlan,
|
||||
billingPeriod,
|
||||
currency,
|
||||
provider,
|
||||
successUrl: `${base}/dashboard/subscription?payment=success`,
|
||||
failureUrl: `${base}/dashboard/subscription?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 = PLAN_PRICES[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-amber-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-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-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-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{p === 'MONTHLY' ? copy.monthly : copy.annual}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Currency selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
{(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCurrency(c)}
|
||||
className={`px-3 py-1 rounded-lg text-sm font-medium border transition-colors ${
|
||||
currency === c ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Plan cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{PLANS.map((plan) => {
|
||||
const price = PLAN_PRICES[plan]?.[billingPeriod]?.[currency]
|
||||
const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE'
|
||||
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, currency) : '—'}
|
||||
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1">
|
||||
{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, currency) : '—'}
|
||||
<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, inv.currency)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,16 +13,27 @@ function initials(m: TeamMember) {
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string | null, language: 'en' | 'fr' | 'ar') {
|
||||
if (!dateStr) return language === 'fr' ? 'Jamais' : language === 'ar' ? 'أبداً' : 'Never'
|
||||
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 `${mins}m ago`
|
||||
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 `${hrs}h ago`
|
||||
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 `${days}d ago`
|
||||
return new Date(dateStr).toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||||
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[] = [
|
||||
@@ -38,15 +49,22 @@ function avatarColor(id: string) {
|
||||
return AVATAR_BG[hash % AVATAR_BG.length]
|
||||
}
|
||||
|
||||
function RoleBadge({ role }: { role: string }) {
|
||||
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',
|
||||
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',
|
||||
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}`}>
|
||||
{role.charAt(0) + role.slice(1).toLowerCase()}
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -107,7 +125,7 @@ export default function TeamPage() {
|
||||
},
|
||||
fr: {
|
||||
title: 'Équipe',
|
||||
subtitle: 'Gérez vos employés et leurs niveaux d’accès',
|
||||
subtitle: "Gérez vos employés et leurs niveaux d'accès",
|
||||
inviteMember: 'Inviter un membre',
|
||||
totalMembers: 'Total membres',
|
||||
active: 'Actif',
|
||||
@@ -120,7 +138,7 @@ export default function TeamPage() {
|
||||
agent: 'Agent',
|
||||
allStatuses: 'Tous les statuts',
|
||||
pending: 'En attente',
|
||||
loadingTeam: 'Chargement de l’équipe…',
|
||||
loadingTeam: "Chargement de l'équipe…",
|
||||
noMembers: 'Aucun membre ne correspond aux filtres',
|
||||
member: 'Membre',
|
||||
role: 'Rôle',
|
||||
@@ -363,7 +381,7 @@ export default function TeamPage() {
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<RoleBadge role={member.role} />
|
||||
<RoleBadge role={member.role} language={language} />
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@@ -4,12 +4,16 @@ import { DashboardLanguageSwitcher, DashboardThemeSwitcher } from '@/components/
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-slate-50 transition-colors dark:bg-slate-950">
|
||||
<Sidebar />
|
||||
<div className="ms-0 flex min-h-screen min-w-0 flex-1 flex-col lg:ms-64">
|
||||
<TopBar />
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-6 py-4 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/90">
|
||||
<div className="flex min-h-screen bg-slate-50 transition-colors dark:bg-slate-950 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">{children}</main>
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-6 py-4 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/90 print:hidden">
|
||||
<div className="flex flex-col items-center justify-between gap-3 lg:flex-row">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-slate-500">
|
||||
Workspace preferences
|
||||
|
||||
Reference in New Issue
Block a user