fix signin signout and apply the new style
This commit is contained in:
@@ -0,0 +1,654 @@
|
||||
'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
|
||||
invoiceNumber: string | null
|
||||
contractNumber: string | null
|
||||
status: string
|
||||
paymentStatus: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
totalAmount: number
|
||||
depositAmount: number
|
||||
paidAmount: number
|
||||
customer: { firstName: string; lastName: string; email: string }
|
||||
vehicle: { make: string; model: string; licensePlate: string }
|
||||
}
|
||||
|
||||
type PaymentRow = {
|
||||
id: string
|
||||
reservationId: string
|
||||
amount: number
|
||||
currency: 'MAD' | 'USD' | 'EUR'
|
||||
status: string
|
||||
type: 'CHARGE' | 'DEPOSIT'
|
||||
paymentProvider: 'AMANPAY' | 'PAYPAL'
|
||||
paymentMethod: string | null
|
||||
paidAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type BillingRow = ReservationRow & {
|
||||
balanceDue: number
|
||||
paymentCount: number
|
||||
latestPayment: PaymentRow | null
|
||||
}
|
||||
|
||||
type ManualPaymentMethod = 'CASH' | 'CHECK' | 'BANK_TRANSFER' | 'CARD' | 'PAYPAL'
|
||||
|
||||
const PAYMENT_STATUS_BADGE: Record<string, string> = {
|
||||
UNPAID: 'bg-rose-100 text-rose-700',
|
||||
PAID: 'bg-emerald-100 text-emerald-700',
|
||||
PARTIAL: 'bg-orange-100 text-orange-700',
|
||||
PENDING: 'bg-sky-100 text-sky-700',
|
||||
REFUNDED: 'bg-slate-100 text-slate-700',
|
||||
FAILED: 'bg-rose-100 text-rose-700',
|
||||
OVERDUE: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const PAYMENT_EVENT_BADGE: Record<string, string> = {
|
||||
PENDING: 'bg-orange-100 text-orange-700',
|
||||
SUCCEEDED: 'bg-emerald-100 text-emerald-700',
|
||||
FAILED: 'bg-rose-100 text-rose-700',
|
||||
REFUNDED: 'bg-slate-100 text-slate-700',
|
||||
PARTIALLY_REFUNDED: 'bg-sky-100 text-sky-700',
|
||||
}
|
||||
|
||||
export default function BillingPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
|
||||
const [reservations, setReservations] = useState<ReservationRow[]>([])
|
||||
const [payments, setPayments] = useState<PaymentRow[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [paymentModalRow, setPaymentModalRow] = useState<BillingRow | null>(null)
|
||||
const [paymentMethod, setPaymentMethod] = useState<ManualPaymentMethod>('CASH')
|
||||
const [paymentType, setPaymentType] = useState<'CHARGE' | 'DEPOSIT'>('CHARGE')
|
||||
const [paymentCurrency, setPaymentCurrency] = useState<'MAD' | 'USD' | 'EUR'>('MAD')
|
||||
const [paymentAmount, setPaymentAmount] = useState('')
|
||||
const [submittingPayment, setSubmittingPayment] = useState(false)
|
||||
const [paymentError, setPaymentError] = useState<string | null>(null)
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
heading: 'Customer Billing',
|
||||
subtitle: 'Manage customer invoices, payment status, collected amounts, and remaining balances.',
|
||||
search: 'Search by customer, invoice, contract, vehicle, or plate…',
|
||||
totalBilled: 'Total billed',
|
||||
totalCollected: 'Collected',
|
||||
outstanding: 'Outstanding',
|
||||
openInvoices: 'Open invoices',
|
||||
customer: 'Customer',
|
||||
vehicle: 'Vehicle',
|
||||
invoice: 'Invoice',
|
||||
rentalPeriod: 'Rental period',
|
||||
total: 'Total',
|
||||
paid: 'Paid',
|
||||
balance: 'Balance',
|
||||
status: 'Status',
|
||||
payment: 'Payment',
|
||||
paymentMethod: 'Payment method',
|
||||
actions: 'Actions',
|
||||
contract: 'Contract',
|
||||
deposit: 'Deposit',
|
||||
acceptPayment: 'Accept payment',
|
||||
paymentModalTitle: 'Accept customer payment',
|
||||
paymentModalSubtitle: 'Record a payment received for this reservation. No online provider setup is required.',
|
||||
paymentType: 'Payment type',
|
||||
paymentCurrency: 'Payment currency',
|
||||
paymentAmount: 'Payment amount',
|
||||
cancel: 'Cancel',
|
||||
savingPayment: 'Saving payment…',
|
||||
savePayment: 'Save payment',
|
||||
chargeHelp: 'Charge records a payment against the reservation balance.',
|
||||
depositHelp: 'Deposit records money received for the refundable security deposit.',
|
||||
invalidAmount: 'Enter a valid payment amount within the remaining balance.',
|
||||
paymentActionDisabled: 'No balance due',
|
||||
paymentsRecorded: (count: number) => `${count} payment(s)`,
|
||||
none: '—',
|
||||
loading: 'Loading customer billing…',
|
||||
noRows: 'No customer bills found.',
|
||||
failed: 'Failed to load customer billing.',
|
||||
openContract: 'Open contract',
|
||||
openBooking: 'Open booking',
|
||||
charge: 'Charge',
|
||||
depositType: 'Deposit',
|
||||
unknownInvoice: 'Draft invoice',
|
||||
paymentStatusLabels: {
|
||||
UNPAID: 'Unpaid',
|
||||
PAID: 'Paid',
|
||||
PARTIAL: 'Partial',
|
||||
PENDING: 'Pending',
|
||||
REFUNDED: 'Refunded',
|
||||
FAILED: 'Failed',
|
||||
OVERDUE: 'Overdue',
|
||||
} as Record<string, string>,
|
||||
paymentEventLabels: {
|
||||
PENDING: 'Pending',
|
||||
SUCCEEDED: 'Succeeded',
|
||||
FAILED: 'Failed',
|
||||
REFUNDED: 'Refunded',
|
||||
PARTIALLY_REFUNDED: 'Partially refunded',
|
||||
} as Record<string, string>,
|
||||
paymentProviderLabels: {
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<string, string>,
|
||||
paymentMethodLabels: {
|
||||
CASH: 'Cash',
|
||||
CHECK: 'Check',
|
||||
BANK_TRANSFER: 'Bank transfer',
|
||||
CARD: 'Card',
|
||||
PAYPAL: 'PayPal',
|
||||
STRIPE: 'Card',
|
||||
CREDIT_CARD: 'Card',
|
||||
DEBIT_CARD: 'Card',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
fr: {
|
||||
heading: 'Facturation clients',
|
||||
subtitle: 'Gérez les factures clients, le statut des paiements, les montants encaissés et les soldes restants.',
|
||||
search: 'Rechercher par client, facture, contrat, véhicule ou plaque…',
|
||||
totalBilled: 'Total facturé',
|
||||
totalCollected: 'Encaissé',
|
||||
outstanding: 'Solde restant',
|
||||
openInvoices: 'Factures ouvertes',
|
||||
customer: 'Client',
|
||||
vehicle: 'Véhicule',
|
||||
invoice: 'Facture',
|
||||
rentalPeriod: 'Période',
|
||||
total: 'Total',
|
||||
paid: 'Payé',
|
||||
balance: 'Solde',
|
||||
status: 'Statut',
|
||||
payment: 'Paiement',
|
||||
paymentMethod: 'Mode de paiement',
|
||||
actions: 'Actions',
|
||||
contract: 'Contrat',
|
||||
deposit: 'Dépôt',
|
||||
acceptPayment: 'Encaisser',
|
||||
paymentModalTitle: 'Encaisser un paiement client',
|
||||
paymentModalSubtitle: 'Enregistrez un paiement reçu pour cette réservation. Aucune configuration de fournisseur en ligne n’est requise.',
|
||||
paymentType: 'Type de paiement',
|
||||
paymentCurrency: 'Devise du paiement',
|
||||
paymentAmount: 'Montant du paiement',
|
||||
cancel: 'Annuler',
|
||||
savingPayment: 'Enregistrement…',
|
||||
savePayment: 'Enregistrer le paiement',
|
||||
chargeHelp: 'Le paiement enregistre un règlement sur le solde de la réservation.',
|
||||
depositHelp: 'Le dépôt enregistre l’encaissement du dépôt de garantie remboursable.',
|
||||
invalidAmount: 'Saisissez un montant valide dans la limite du solde restant.',
|
||||
paymentActionDisabled: 'Aucun solde dû',
|
||||
paymentsRecorded: (count: number) => `${count} paiement(s)`,
|
||||
none: '—',
|
||||
loading: 'Chargement de la facturation client…',
|
||||
noRows: 'Aucune facture client trouvée.',
|
||||
failed: 'Échec du chargement de la facturation client.',
|
||||
openContract: 'Ouvrir le contrat',
|
||||
openBooking: 'Ouvrir la réservation',
|
||||
charge: 'Paiement',
|
||||
depositType: 'Dépôt',
|
||||
unknownInvoice: 'Facture brouillon',
|
||||
paymentStatusLabels: {
|
||||
UNPAID: 'Non payé',
|
||||
PAID: 'Payé',
|
||||
PARTIAL: 'Partiel',
|
||||
PENDING: 'En attente',
|
||||
REFUNDED: 'Remboursé',
|
||||
FAILED: 'Échoué',
|
||||
OVERDUE: 'En retard',
|
||||
} as Record<string, string>,
|
||||
paymentEventLabels: {
|
||||
PENDING: 'En attente',
|
||||
SUCCEEDED: 'Réussi',
|
||||
FAILED: 'Échoué',
|
||||
REFUNDED: 'Remboursé',
|
||||
PARTIALLY_REFUNDED: 'Partiellement remboursé',
|
||||
} as Record<string, string>,
|
||||
paymentProviderLabels: {
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<string, string>,
|
||||
paymentMethodLabels: {
|
||||
CASH: 'Espèces',
|
||||
CHECK: 'Chèque',
|
||||
BANK_TRANSFER: 'Virement bancaire',
|
||||
CARD: 'Carte',
|
||||
PAYPAL: 'PayPal',
|
||||
STRIPE: 'Carte',
|
||||
CREDIT_CARD: 'Carte',
|
||||
DEBIT_CARD: 'Carte',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
ar: {
|
||||
heading: 'فوترة العملاء',
|
||||
subtitle: 'إدارة فواتير العملاء وحالة الدفع والمبالغ المحصلة والأرصدة المتبقية.',
|
||||
search: 'ابحث بالعميل أو الفاتورة أو العقد أو السيارة أو اللوحة…',
|
||||
totalBilled: 'إجمالي الفواتير',
|
||||
totalCollected: 'المحصّل',
|
||||
outstanding: 'المتبقي',
|
||||
openInvoices: 'الفواتير المفتوحة',
|
||||
customer: 'العميل',
|
||||
vehicle: 'المركبة',
|
||||
invoice: 'الفاتورة',
|
||||
rentalPeriod: 'فترة الإيجار',
|
||||
total: 'الإجمالي',
|
||||
paid: 'المدفوع',
|
||||
balance: 'الرصيد',
|
||||
status: 'الحالة',
|
||||
payment: 'الدفعة',
|
||||
paymentMethod: 'طريقة الدفع',
|
||||
actions: 'الإجراءات',
|
||||
contract: 'العقد',
|
||||
deposit: 'العربون',
|
||||
acceptPayment: 'تحصيل دفعة',
|
||||
paymentModalTitle: 'تحصيل دفعة من العميل',
|
||||
paymentModalSubtitle: 'سجّل دفعة مستلمة لهذا الحجز. لا يتطلب ذلك إعداد مزود دفع عبر الإنترنت.',
|
||||
paymentType: 'نوع الدفع',
|
||||
paymentCurrency: 'عملة الدفع',
|
||||
paymentAmount: 'مبلغ الدفعة',
|
||||
cancel: 'إلغاء',
|
||||
savingPayment: 'جارٍ الحفظ…',
|
||||
savePayment: 'حفظ الدفعة',
|
||||
chargeHelp: 'تسجّل الدفعة مبلغاً مستلماً على رصيد الحجز.',
|
||||
depositHelp: 'العربون يسجل مبلغ التأمين القابل للاسترداد المستلم.',
|
||||
invalidAmount: 'أدخل مبلغاً صحيحاً ضمن الرصيد المتبقي.',
|
||||
paymentActionDisabled: 'لا يوجد رصيد مستحق',
|
||||
paymentsRecorded: (count: number) => `${count} دفعة`,
|
||||
none: '—',
|
||||
loading: 'جارٍ تحميل فوترة العملاء…',
|
||||
noRows: 'لم يتم العثور على فواتير عملاء.',
|
||||
failed: 'فشل تحميل فوترة العملاء.',
|
||||
openContract: 'فتح العقد',
|
||||
openBooking: 'فتح الحجز',
|
||||
charge: 'دفعة',
|
||||
depositType: 'عربون',
|
||||
unknownInvoice: 'فاتورة مسودة',
|
||||
paymentStatusLabels: {
|
||||
UNPAID: 'غير مدفوع',
|
||||
PAID: 'مدفوع',
|
||||
PARTIAL: 'جزئي',
|
||||
PENDING: 'قيد الانتظار',
|
||||
REFUNDED: 'مسترد',
|
||||
FAILED: 'فشل',
|
||||
OVERDUE: 'متأخر',
|
||||
} as Record<string, string>,
|
||||
paymentEventLabels: {
|
||||
PENDING: 'قيد الانتظار',
|
||||
SUCCEEDED: 'ناجح',
|
||||
FAILED: 'فشل',
|
||||
REFUNDED: 'مسترد',
|
||||
PARTIALLY_REFUNDED: 'مسترد جزئياً',
|
||||
} as Record<string, string>,
|
||||
paymentProviderLabels: {
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<string, string>,
|
||||
paymentMethodLabels: {
|
||||
CASH: 'نقداً',
|
||||
CHECK: 'شيك',
|
||||
BANK_TRANSFER: 'تحويل بنكي',
|
||||
CARD: 'بطاقة',
|
||||
PAYPAL: 'PayPal',
|
||||
STRIPE: 'بطاقة',
|
||||
CREDIT_CARD: 'بطاقة',
|
||||
DEBIT_CARD: 'بطاقة',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
}[language]
|
||||
|
||||
async function loadBillingData() {
|
||||
const [reservationRows, paymentRows] = await Promise.all([
|
||||
apiFetch<ReservationRow[]>('/reservations?pageSize=100'),
|
||||
apiFetch<PaymentRow[]>('/payments/company'),
|
||||
])
|
||||
setReservations(reservationRows ?? [])
|
||||
setPayments(paymentRows ?? [])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadBillingData()
|
||||
.catch((err) => setError(err.message ?? copy.failed))
|
||||
.finally(() => setLoading(false))
|
||||
}, [copy.failed])
|
||||
|
||||
const billingRows = useMemo<BillingRow[]>(() => {
|
||||
const paymentsByReservation = new Map<string, PaymentRow[]>()
|
||||
|
||||
for (const payment of payments) {
|
||||
const existing = paymentsByReservation.get(payment.reservationId) ?? []
|
||||
existing.push(payment)
|
||||
paymentsByReservation.set(payment.reservationId, existing)
|
||||
}
|
||||
|
||||
return reservations.map((reservation) => {
|
||||
const reservationPayments = (paymentsByReservation.get(reservation.id) ?? []).sort((a, b) =>
|
||||
new Date(b.paidAt ?? b.createdAt).getTime() - new Date(a.paidAt ?? a.createdAt).getTime(),
|
||||
)
|
||||
|
||||
return {
|
||||
...reservation,
|
||||
balanceDue: Math.max(reservation.totalAmount - reservation.paidAmount, 0),
|
||||
paymentCount: reservationPayments.length,
|
||||
latestPayment: reservationPayments[0] ?? null,
|
||||
}
|
||||
})
|
||||
}, [payments, reservations])
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return billingRows
|
||||
return billingRows.filter((row) =>
|
||||
`${row.customer.firstName} ${row.customer.lastName}`.toLowerCase().includes(q) ||
|
||||
row.customer.email.toLowerCase().includes(q) ||
|
||||
`${row.vehicle.make} ${row.vehicle.model}`.toLowerCase().includes(q) ||
|
||||
row.vehicle.licensePlate.toLowerCase().includes(q) ||
|
||||
(row.invoiceNumber ?? '').toLowerCase().includes(q) ||
|
||||
(row.contractNumber ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
}, [billingRows, search])
|
||||
|
||||
const totals = useMemo(() => {
|
||||
return filteredRows.reduce(
|
||||
(acc, row) => {
|
||||
acc.totalBilled += row.totalAmount
|
||||
acc.totalCollected += row.paidAmount
|
||||
acc.outstanding += row.balanceDue
|
||||
if (row.balanceDue > 0) acc.openInvoices += 1
|
||||
return acc
|
||||
},
|
||||
{ totalBilled: 0, totalCollected: 0, outstanding: 0, openInvoices: 0 },
|
||||
)
|
||||
}, [filteredRows])
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString(localeCode, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function translateStatus(status: string, labels: Record<string, string>) {
|
||||
return labels[status] ?? status.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
function translatePaymentMethod(method: string | null) {
|
||||
if (!method) return copy.none
|
||||
return copy.paymentMethodLabels[method] ?? method.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
function canAcceptPayment(row: BillingRow) {
|
||||
return row.balanceDue > 0
|
||||
}
|
||||
|
||||
function openPaymentModal(row: BillingRow) {
|
||||
setPaymentModalRow(row)
|
||||
setPaymentMethod('CASH')
|
||||
setPaymentType('CHARGE')
|
||||
setPaymentCurrency('MAD')
|
||||
setPaymentAmount(String((row.balanceDue / 100).toFixed(2)))
|
||||
setPaymentError(null)
|
||||
}
|
||||
|
||||
async function handleAcceptPayment() {
|
||||
if (!paymentModalRow) return
|
||||
|
||||
const amount = Math.round(Number(paymentAmount) * 100)
|
||||
if (!Number.isFinite(amount) || amount <= 0 || amount > paymentModalRow.balanceDue) {
|
||||
setPaymentError(copy.invalidAmount)
|
||||
return
|
||||
}
|
||||
|
||||
setSubmittingPayment(true)
|
||||
setPaymentError(null)
|
||||
|
||||
try {
|
||||
await apiFetch<PaymentRow>(`/payments/reservations/${paymentModalRow.id}/manual`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
amount,
|
||||
type: paymentType,
|
||||
currency: paymentCurrency,
|
||||
paymentMethod,
|
||||
}),
|
||||
})
|
||||
await loadBillingData()
|
||||
setPaymentModalRow(null)
|
||||
} catch (err: any) {
|
||||
setPaymentError(err.message ?? copy.failed)
|
||||
} finally {
|
||||
setSubmittingPayment(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{copy.heading}</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
|
||||
</div>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={copy.search}
|
||||
className="input-field w-full lg:max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard label={copy.totalBilled} value={formatCurrency(totals.totalBilled, 'MAD')} />
|
||||
<MetricCard label={copy.totalCollected} value={formatCurrency(totals.totalCollected, 'MAD')} />
|
||||
<MetricCard label={copy.outstanding} value={formatCurrency(totals.outstanding, 'MAD')} />
|
||||
<MetricCard label={copy.openInvoices} value={totals.openInvoices.toLocaleString(localeCode)} />
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{error ? (
|
||||
<div className="p-6 text-sm text-red-600">{error}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.customer}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.invoice}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.rentalPeriod}</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.paid}</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.balance}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.status}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.payment}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.paymentMethod}</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td>
|
||||
</tr>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noRows}</td>
|
||||
</tr>
|
||||
) : filteredRows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<p className="font-semibold text-slate-900">{row.customer.firstName} {row.customer.lastName}</p>
|
||||
<p className="text-xs text-slate-500">{row.customer.email}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">
|
||||
<p>{row.vehicle.make} {row.vehicle.model}</p>
|
||||
<p className="text-xs text-slate-500">{row.vehicle.licensePlate}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">
|
||||
<p className="font-medium text-slate-900">{row.invoiceNumber ?? copy.unknownInvoice}</p>
|
||||
<p className="text-xs text-slate-500">{copy.contract}: {row.contractNumber ?? copy.none}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-600">
|
||||
<p>{formatDate(row.startDate)}</p>
|
||||
<p>{formatDate(row.endDate)}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">
|
||||
{formatCurrency(row.totalAmount, 'MAD')}
|
||||
{row.depositAmount > 0 ? (
|
||||
<p className="text-xs font-normal text-slate-500">{copy.deposit}: {formatCurrency(row.depositAmount, 'MAD')}</p>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-semibold text-emerald-700">{formatCurrency(row.paidAmount, 'MAD')}</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-semibold text-orange-700">{formatCurrency(row.balanceDue, 'MAD')}</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${PAYMENT_STATUS_BADGE[row.paymentStatus] ?? 'bg-slate-100 text-slate-700'}`}>
|
||||
{translateStatus(row.paymentStatus, copy.paymentStatusLabels)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">
|
||||
{row.latestPayment ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${PAYMENT_EVENT_BADGE[row.latestPayment.status] ?? 'bg-slate-100 text-slate-700'}`}>
|
||||
{translateStatus(row.latestPayment.status, copy.paymentEventLabels)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{copy.paymentProviderLabels[row.latestPayment.paymentProvider] ?? row.latestPayment.paymentProvider}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{row.latestPayment.type === 'DEPOSIT' ? copy.depositType : copy.charge} · {formatCurrency(row.latestPayment.amount, row.latestPayment.currency)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">{formatDate(row.latestPayment.paidAt ?? row.latestPayment.createdAt)} · {copy.paymentsRecorded(row.paymentCount)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-400">{copy.none}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">
|
||||
{row.latestPayment ? (
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-slate-900">{translatePaymentMethod(row.latestPayment.paymentMethod)}</p>
|
||||
<p className="text-xs text-slate-500">{copy.paymentProviderLabels[row.latestPayment.paymentProvider] ?? row.latestPayment.paymentProvider}</p>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-400">{copy.none}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-sm">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{canAcceptPayment(row) ? (
|
||||
<button type="button" onClick={() => openPaymentModal(row)} className="font-semibold text-emerald-700 hover:underline">
|
||||
{copy.acceptPayment}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">{copy.paymentActionDisabled}</span>
|
||||
)}
|
||||
<Link href={`/contracts/${row.id}`} className="font-semibold text-blue-700 hover:underline">
|
||||
{copy.openContract}
|
||||
</Link>
|
||||
<Link href={`/reservations/${row.id}`} className="text-slate-600 hover:text-slate-900 hover:underline">
|
||||
{copy.openBooking}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{paymentModalRow ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#07101e]/30 backdrop-blur-sm" onClick={(event) => {
|
||||
if (event.target === event.currentTarget && !submittingPayment) setPaymentModalRow(null)
|
||||
}}>
|
||||
<div className="w-full max-w-lg rounded-3xl bg-white p-6 shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">{copy.paymentModalTitle}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.paymentModalSubtitle}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setPaymentModalRow(null)} disabled={submittingPayment} className="text-slate-400 transition hover:text-slate-700">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
<p className="font-semibold text-slate-900">{paymentModalRow.customer.firstName} {paymentModalRow.customer.lastName}</p>
|
||||
<p className="mt-1">{paymentModalRow.vehicle.make} {paymentModalRow.vehicle.model} · {paymentModalRow.vehicle.licensePlate}</p>
|
||||
<p className="mt-1">{copy.invoice}: {paymentModalRow.invoiceNumber ?? copy.unknownInvoice}</p>
|
||||
<p className="mt-2 font-semibold text-slate-900">{copy.balance}: {formatCurrency(paymentModalRow.balanceDue, paymentCurrency)}</p>
|
||||
</div>
|
||||
|
||||
{paymentError ? (
|
||||
<div className="mt-5 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
|
||||
{paymentError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentMethod}</label>
|
||||
<select className="input-field" value={paymentMethod} onChange={(event) => setPaymentMethod(event.target.value as ManualPaymentMethod)} disabled={submittingPayment}>
|
||||
<option value="CASH">{copy.paymentMethodLabels.CASH}</option>
|
||||
<option value="CHECK">{copy.paymentMethodLabels.CHECK}</option>
|
||||
<option value="BANK_TRANSFER">{copy.paymentMethodLabels.BANK_TRANSFER}</option>
|
||||
<option value="CARD">{copy.paymentMethodLabels.CARD}</option>
|
||||
<option value="PAYPAL">{copy.paymentMethodLabels.PAYPAL}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentType}</label>
|
||||
<select className="input-field" value={paymentType} onChange={(event) => setPaymentType(event.target.value as 'CHARGE' | 'DEPOSIT')} disabled={submittingPayment}>
|
||||
{paymentModalRow.depositAmount > 0 ? <option value="DEPOSIT">{copy.depositType}</option> : null}
|
||||
<option value="CHARGE">{copy.charge}</option>
|
||||
</select>
|
||||
<p className="mt-2 text-xs text-slate-500">{paymentType === 'DEPOSIT' ? copy.depositHelp : copy.chargeHelp}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentCurrency}</label>
|
||||
<select className="input-field" value={paymentCurrency} onChange={(event) => setPaymentCurrency(event.target.value as 'MAD' | 'USD' | 'EUR')} disabled={submittingPayment}>
|
||||
<option value="MAD">MAD</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentAmount}</label>
|
||||
<input type="number" min="0" step="0.01" className="input-field" value={paymentAmount} onChange={(event) => setPaymentAmount(event.target.value)} disabled={submittingPayment} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button type="button" onClick={() => setPaymentModalRow(null)} disabled={submittingPayment} className="flex-1 rounded-full border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
|
||||
{copy.cancel}
|
||||
</button>
|
||||
<button type="button" onClick={handleAcceptPayment} disabled={submittingPayment} className="btn-primary flex-1 justify-center disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{submittingPayment ? copy.savingPayment : copy.savePayment}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<p className="text-sm font-medium text-slate-500">{label}</p>
|
||||
<p className="mt-2 text-2xl font-bold text-slate-900">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user