8f5ca1df22
Test / Type Check (all packages) (push) Has been cancelled
Test / API Unit Tests (push) Has been cancelled
Test / Homepage Unit Tests (push) Has been cancelled
Test / Carplace Unit Tests (push) Has been cancelled
Test / Admin Unit Tests (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
Build & Push / Pipeline Tests (push) Waiting to run
Build & Push / Build & Push Docker Image (push) Blocked by required conditions
654 lines
31 KiB
TypeScript
654 lines
31 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||
import Link from 'next/link'
|
||
import { useSearchParams } from 'next/navigation'
|
||
import { formatCurrency, SupportedCurrency } from '@rentaldrivego/types'
|
||
import { apiFetch } from '@/lib/api'
|
||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||
|
||
type BillingPaymentType = 'CHARGE' | 'DEPOSIT'
|
||
type ManualPaymentMethod = 'CASH' | 'CHECK' | 'BANK_TRANSFER' | 'CARD' | 'PAYPAL' | 'OTHER'
|
||
|
||
type BillingPayment = {
|
||
id: string
|
||
amountMinor: number
|
||
currency: SupportedCurrency
|
||
type: BillingPaymentType
|
||
channel: 'ONLINE' | 'OFFLINE' | 'TERMINAL'
|
||
provider: string
|
||
method: string | null
|
||
status: string
|
||
reference: string | null
|
||
receivedAt: string
|
||
createdAt: string
|
||
recordedBy: { name: string; email: string } | null
|
||
}
|
||
|
||
type BillingInvoice = {
|
||
id: string
|
||
reservationId: string
|
||
invoiceNumber: string | null
|
||
contractNumber: string | null
|
||
customer: { firstName: string; lastName: string; email: string }
|
||
vehicle: { make: string; model: string; licensePlate: string }
|
||
rentalPeriod: { startDate: string; endDate: string }
|
||
currency: SupportedCurrency
|
||
paymentStatus: 'UNPAID' | 'PARTIAL' | 'PAID'
|
||
invoiceTotal: number
|
||
invoicePaid: number
|
||
invoiceBalanceDue: number
|
||
depositRequired: number
|
||
depositCollected: number
|
||
depositHeld: number
|
||
depositOutstanding: number
|
||
depositStatus: string
|
||
paymentCount: number
|
||
latestPayment: BillingPayment | null
|
||
}
|
||
|
||
type BillingSummary = {
|
||
currency: SupportedCurrency
|
||
totalInvoiced: number
|
||
totalCollected: number
|
||
totalRefunded: number
|
||
totalOutstanding: number
|
||
depositsHeld: number
|
||
openInvoiceCount: number
|
||
overdueInvoiceCount: number
|
||
}
|
||
|
||
type BillingInvoiceResponse = {
|
||
items: BillingInvoice[]
|
||
page: number
|
||
pageSize: number
|
||
totalItems: number
|
||
totalPages: number
|
||
}
|
||
|
||
const STATUS_BADGE: Record<string, string> = {
|
||
UNPAID: 'bg-rose-100 text-rose-700',
|
||
PARTIAL: 'bg-orange-100 text-orange-700',
|
||
PAID: 'bg-emerald-100 text-emerald-700',
|
||
OUTSTANDING: 'bg-rose-100 text-rose-700',
|
||
PARTIALLY_COLLECTED: 'bg-orange-100 text-orange-700',
|
||
HELD: 'bg-emerald-100 text-emerald-700',
|
||
NOT_REQUIRED: 'bg-slate-100 text-slate-700',
|
||
}
|
||
|
||
export default function BillingPage() {
|
||
const { language } = useDashboardI18n()
|
||
const searchParams = useSearchParams()
|
||
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
|
||
const querySearch = searchParams.get('search') ?? ''
|
||
const [summary, setSummary] = useState<BillingSummary | null>(null)
|
||
const [invoices, setInvoices] = useState<BillingInvoiceResponse | null>(null)
|
||
const [search, setSearch] = useState('')
|
||
const [submittedSearch, setSubmittedSearch] = useState('')
|
||
const [paymentStatus, setPaymentStatus] = useState<'ALL' | 'UNPAID' | 'PARTIAL' | 'PAID'>('ALL')
|
||
const [outstandingOnly, setOutstandingOnly] = useState(false)
|
||
const [page, setPage] = useState(1)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [paymentInvoice, setPaymentInvoice] = useState<BillingInvoice | null>(null)
|
||
const [paymentType, setPaymentType] = useState<BillingPaymentType>('CHARGE')
|
||
const [paymentMethod, setPaymentMethod] = useState<ManualPaymentMethod>('CASH')
|
||
const [paymentAmount, setPaymentAmount] = useState('')
|
||
const [receivedAt, setReceivedAt] = useState('')
|
||
const [reference, setReference] = useState('')
|
||
const [note, setNote] = useState('')
|
||
const [submittingPayment, setSubmittingPayment] = useState(false)
|
||
const [paymentError, setPaymentError] = useState<string | null>(null)
|
||
|
||
const copy = useMemo(() => ({
|
||
en: {
|
||
heading: 'Customer Billing',
|
||
subtitle: 'Customer invoices, rental payments, deposits, and balances. Subscription billing is managed separately.',
|
||
search: 'Search customer, invoice, contract, vehicle, or plate',
|
||
applySearch: 'Search',
|
||
allStatuses: 'All statuses',
|
||
outstandingOnly: 'Outstanding only',
|
||
totalInvoiced: 'Total invoiced',
|
||
collected: 'Collected',
|
||
outstanding: 'Outstanding',
|
||
depositsHeld: 'Deposits held',
|
||
openInvoices: 'Open invoices',
|
||
invoiceCustomer: 'Invoice and customer',
|
||
vehicle: 'Vehicle',
|
||
period: 'Rental period',
|
||
invoiceTotal: 'Invoice total',
|
||
paid: 'Paid',
|
||
balance: 'Balance',
|
||
deposit: 'Deposit',
|
||
status: 'Status',
|
||
actions: 'Actions',
|
||
recordPayment: 'Record payment',
|
||
noPaymentDue: 'No payment due',
|
||
openContract: 'Open contract',
|
||
openBooking: 'Open booking',
|
||
loading: 'Loading customer billing...',
|
||
failed: 'Failed to load customer billing.',
|
||
retry: 'Retry',
|
||
empty: 'No customer billing records found.',
|
||
previous: 'Previous',
|
||
next: 'Next',
|
||
pageLabel: (current: number, total: number) => `Page ${current} of ${total}`,
|
||
unknownInvoice: 'Draft invoice',
|
||
none: '-',
|
||
paymentDialogTitle: 'Record manual payment',
|
||
paymentDialogDescription: 'Record an offline payment against the correct invoice or security-deposit balance.',
|
||
paymentTarget: 'Payment target',
|
||
charge: 'Invoice charge',
|
||
securityDeposit: 'Security deposit',
|
||
remaining: 'Remaining',
|
||
paymentMethod: 'Payment method',
|
||
amount: 'Amount',
|
||
currency: 'Currency',
|
||
receivedAt: 'Received at',
|
||
reference: 'Reference',
|
||
note: 'Internal note',
|
||
cancel: 'Cancel',
|
||
save: 'Save payment',
|
||
saving: 'Saving...',
|
||
invalidAmount: 'Enter a valid amount within the selected remaining balance.',
|
||
paymentStatusLabels: { ALL: 'All', UNPAID: 'Unpaid', PARTIAL: 'Partial', PAID: 'Paid' } as Record<string, string>,
|
||
depositStatusLabels: { NOT_REQUIRED: 'Not required', OUTSTANDING: 'Outstanding', PARTIALLY_COLLECTED: 'Partially collected', HELD: 'Held' } as Record<string, string>,
|
||
paymentMethodLabels: { CASH: 'Cash', CHECK: 'Check', BANK_TRANSFER: 'Bank transfer', CARD: 'Card', PAYPAL: 'PayPal', OTHER: 'Other' } as Record<string, string>,
|
||
},
|
||
fr: {
|
||
heading: 'Facturation clients',
|
||
subtitle: 'Factures clients, paiements de location, dépôts et soldes. La facturation d’abonnement est séparée.',
|
||
search: 'Rechercher client, facture, contrat, véhicule ou plaque',
|
||
applySearch: 'Rechercher',
|
||
allStatuses: 'Tous les statuts',
|
||
outstandingOnly: 'Soldes uniquement',
|
||
totalInvoiced: 'Total facturé',
|
||
collected: 'Encaissé',
|
||
outstanding: 'Solde restant',
|
||
depositsHeld: 'Dépôts détenus',
|
||
openInvoices: 'Factures ouvertes',
|
||
invoiceCustomer: 'Facture et client',
|
||
vehicle: 'Véhicule',
|
||
period: 'Période',
|
||
invoiceTotal: 'Total facture',
|
||
paid: 'Payé',
|
||
balance: 'Solde',
|
||
deposit: 'Dépôt',
|
||
status: 'Statut',
|
||
actions: 'Actions',
|
||
recordPayment: 'Encaisser',
|
||
noPaymentDue: 'Aucun montant dû',
|
||
openContract: 'Ouvrir contrat',
|
||
openBooking: 'Ouvrir réservation',
|
||
loading: 'Chargement de la facturation client...',
|
||
failed: 'Échec du chargement de la facturation client.',
|
||
retry: 'Réessayer',
|
||
empty: 'Aucune facture client trouvée.',
|
||
previous: 'Précédent',
|
||
next: 'Suivant',
|
||
pageLabel: (current: number, total: number) => `Page ${current} sur ${total}`,
|
||
unknownInvoice: 'Facture brouillon',
|
||
none: '-',
|
||
paymentDialogTitle: 'Enregistrer un paiement manuel',
|
||
paymentDialogDescription: 'Enregistrez un paiement hors ligne sur le bon solde de facture ou de dépôt de garantie.',
|
||
paymentTarget: 'Cible du paiement',
|
||
charge: 'Facture',
|
||
securityDeposit: 'Dépôt de garantie',
|
||
remaining: 'Restant',
|
||
paymentMethod: 'Mode de paiement',
|
||
amount: 'Montant',
|
||
currency: 'Devise',
|
||
receivedAt: 'Reçu le',
|
||
reference: 'Référence',
|
||
note: 'Note interne',
|
||
cancel: 'Annuler',
|
||
save: 'Enregistrer',
|
||
saving: 'Enregistrement...',
|
||
invalidAmount: 'Saisissez un montant valide dans la limite du solde sélectionné.',
|
||
paymentStatusLabels: { ALL: 'Tous', UNPAID: 'Non payé', PARTIAL: 'Partiel', PAID: 'Payé' } as Record<string, string>,
|
||
depositStatusLabels: { NOT_REQUIRED: 'Non requis', OUTSTANDING: 'À collecter', PARTIALLY_COLLECTED: 'Partiellement collecté', HELD: 'Détenu' } as Record<string, string>,
|
||
paymentMethodLabels: { CASH: 'Espèces', CHECK: 'Chèque', BANK_TRANSFER: 'Virement bancaire', CARD: 'Carte', PAYPAL: 'PayPal', OTHER: 'Autre' } as Record<string, string>,
|
||
},
|
||
ar: {
|
||
heading: 'فوترة العملاء',
|
||
subtitle: 'فواتير العملاء ودفعات الإيجار والضمانات والأرصدة. فوترة الاشتراك تدار بشكل منفصل.',
|
||
search: 'ابحث بالعميل أو الفاتورة أو العقد أو السيارة أو اللوحة',
|
||
applySearch: 'بحث',
|
||
allStatuses: 'كل الحالات',
|
||
outstandingOnly: 'المستحق فقط',
|
||
totalInvoiced: 'إجمالي الفواتير',
|
||
collected: 'المحصّل',
|
||
outstanding: 'المتبقي',
|
||
depositsHeld: 'الضمان المحتجز',
|
||
openInvoices: 'الفواتير المفتوحة',
|
||
invoiceCustomer: 'الفاتورة والعميل',
|
||
vehicle: 'المركبة',
|
||
period: 'الفترة',
|
||
invoiceTotal: 'إجمالي الفاتورة',
|
||
paid: 'المدفوع',
|
||
balance: 'الرصيد',
|
||
deposit: 'الضمان',
|
||
status: 'الحالة',
|
||
actions: 'الإجراءات',
|
||
recordPayment: 'تسجيل دفعة',
|
||
noPaymentDue: 'لا يوجد مبلغ مستحق',
|
||
openContract: 'فتح العقد',
|
||
openBooking: 'فتح الحجز',
|
||
loading: 'جارٍ تحميل فوترة العملاء...',
|
||
failed: 'فشل تحميل فوترة العملاء.',
|
||
retry: 'إعادة المحاولة',
|
||
empty: 'لم يتم العثور على سجلات فوترة.',
|
||
previous: 'السابق',
|
||
next: 'التالي',
|
||
pageLabel: (current: number, total: number) => `الصفحة ${current} من ${total}`,
|
||
unknownInvoice: 'فاتورة مسودة',
|
||
none: '-',
|
||
paymentDialogTitle: 'تسجيل دفعة يدوية',
|
||
paymentDialogDescription: 'سجل دفعة خارجية على رصيد الفاتورة أو الضمان الصحيح.',
|
||
paymentTarget: 'هدف الدفعة',
|
||
charge: 'دفعة الفاتورة',
|
||
securityDeposit: 'ضمان التأمين',
|
||
remaining: 'المتبقي',
|
||
paymentMethod: 'طريقة الدفع',
|
||
amount: 'المبلغ',
|
||
currency: 'العملة',
|
||
receivedAt: 'وقت الاستلام',
|
||
reference: 'المرجع',
|
||
note: 'ملاحظة داخلية',
|
||
cancel: 'إلغاء',
|
||
save: 'حفظ الدفعة',
|
||
saving: 'جارٍ الحفظ...',
|
||
invalidAmount: 'أدخل مبلغاً صحيحاً ضمن الرصيد المحدد.',
|
||
paymentStatusLabels: { ALL: 'الكل', UNPAID: 'غير مدفوع', PARTIAL: 'جزئي', PAID: 'مدفوع' } as Record<string, string>,
|
||
depositStatusLabels: { NOT_REQUIRED: 'غير مطلوب', OUTSTANDING: 'مستحق', PARTIALLY_COLLECTED: 'محصل جزئياً', HELD: 'محتجز' } as Record<string, string>,
|
||
paymentMethodLabels: { CASH: 'نقداً', CHECK: 'شيك', BANK_TRANSFER: 'تحويل بنكي', CARD: 'بطاقة', PAYPAL: 'PayPal', OTHER: 'أخرى' } as Record<string, string>,
|
||
},
|
||
}[language]), [language])
|
||
|
||
useEffect(() => {
|
||
setSearch(querySearch)
|
||
setSubmittedSearch(querySearch)
|
||
setPage(1)
|
||
}, [querySearch])
|
||
|
||
const loadBilling = useCallback(async () => {
|
||
setLoading(true)
|
||
setError(null)
|
||
const params = new URLSearchParams({
|
||
page: String(page),
|
||
pageSize: '10',
|
||
search: submittedSearch,
|
||
paymentStatus,
|
||
outstandingOnly: String(outstandingOnly),
|
||
})
|
||
const summaryParams = new URLSearchParams({ search: submittedSearch })
|
||
const [summaryRows, invoiceRows] = await Promise.all([
|
||
apiFetch<BillingSummary>(`/billing/summary?${summaryParams.toString()}`),
|
||
apiFetch<BillingInvoiceResponse>(`/billing/invoices?${params.toString()}`),
|
||
])
|
||
setSummary(summaryRows)
|
||
setInvoices(invoiceRows)
|
||
setLoading(false)
|
||
}, [outstandingOnly, page, paymentStatus, submittedSearch])
|
||
|
||
useEffect(() => {
|
||
loadBilling().catch((err) => {
|
||
setError(err.message ?? copy.failed)
|
||
setLoading(false)
|
||
})
|
||
}, [copy.failed, loadBilling])
|
||
|
||
useEffect(() => {
|
||
function handleKeyDown(event: KeyboardEvent) {
|
||
if (event.key === 'Escape' && paymentInvoice && !submittingPayment) {
|
||
setPaymentInvoice(null)
|
||
}
|
||
}
|
||
window.addEventListener('keydown', handleKeyDown)
|
||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||
}, [paymentInvoice, submittingPayment])
|
||
|
||
const selectedRemaining = paymentInvoice
|
||
? paymentType === 'DEPOSIT'
|
||
? paymentInvoice.depositOutstanding
|
||
: paymentInvoice.invoiceBalanceDue
|
||
: 0
|
||
|
||
function formatDate(value: string) {
|
||
return new Date(value).toLocaleDateString(localeCode, {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
year: 'numeric',
|
||
})
|
||
}
|
||
|
||
function formatMoney(amount: number, currency: SupportedCurrency = summary?.currency ?? 'MAD') {
|
||
return formatCurrency(amount, currency)
|
||
}
|
||
|
||
function openPaymentDialog(invoice: BillingInvoice) {
|
||
const nextType = invoice.invoiceBalanceDue > 0 ? 'CHARGE' : 'DEPOSIT'
|
||
const remaining = nextType === 'DEPOSIT' ? invoice.depositOutstanding : invoice.invoiceBalanceDue
|
||
setPaymentInvoice(invoice)
|
||
setPaymentType(nextType)
|
||
setPaymentMethod('CASH')
|
||
setPaymentAmount((remaining / 100).toFixed(2))
|
||
setReceivedAt(new Date().toISOString().slice(0, 16))
|
||
setReference('')
|
||
setNote('')
|
||
setPaymentError(null)
|
||
}
|
||
|
||
function updatePaymentType(type: BillingPaymentType) {
|
||
setPaymentType(type)
|
||
if (!paymentInvoice) return
|
||
const remaining = type === 'DEPOSIT' ? paymentInvoice.depositOutstanding : paymentInvoice.invoiceBalanceDue
|
||
setPaymentAmount((remaining / 100).toFixed(2))
|
||
}
|
||
|
||
async function submitPayment() {
|
||
if (!paymentInvoice) return
|
||
const amountMinor = Math.round(Number(paymentAmount) * 100)
|
||
if (!Number.isFinite(amountMinor) || amountMinor <= 0 || amountMinor > selectedRemaining) {
|
||
setPaymentError(copy.invalidAmount)
|
||
return
|
||
}
|
||
|
||
setSubmittingPayment(true)
|
||
setPaymentError(null)
|
||
try {
|
||
await apiFetch(`/billing/invoices/${paymentInvoice.id}/payments/manual`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
amountMinor,
|
||
currency: paymentInvoice.currency,
|
||
type: paymentType,
|
||
method: paymentMethod,
|
||
receivedAt: receivedAt ? new Date(receivedAt).toISOString() : undefined,
|
||
reference: reference.trim() || undefined,
|
||
note: note.trim() || undefined,
|
||
idempotencyKey: crypto.randomUUID(),
|
||
}),
|
||
})
|
||
setPaymentInvoice(null)
|
||
await loadBilling()
|
||
} catch (err: any) {
|
||
setPaymentError(err.message ?? copy.failed)
|
||
} finally {
|
||
setSubmittingPayment(false)
|
||
}
|
||
}
|
||
|
||
const rows = invoices?.items ?? []
|
||
const currentPage = invoices?.page ?? page
|
||
const totalPages = invoices?.totalPages ?? 1
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||
<div>
|
||
<h2 className="text-xl font-semibold text-slate-900">{copy.heading}</h2>
|
||
<p className="mt-1 max-w-3xl text-sm text-slate-500">{copy.subtitle}</p>
|
||
</div>
|
||
<form
|
||
className="flex w-full flex-col gap-3 sm:flex-row xl:max-w-3xl"
|
||
onSubmit={(event) => {
|
||
event.preventDefault()
|
||
setPage(1)
|
||
setSubmittedSearch(search)
|
||
}}
|
||
>
|
||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={copy.search} className="input-field min-w-0 flex-1" />
|
||
<select value={paymentStatus} onChange={(event) => { setPage(1); setPaymentStatus(event.target.value as typeof paymentStatus) }} className="input-field sm:w-44">
|
||
{(['ALL', 'UNPAID', 'PARTIAL', 'PAID'] as const).map((status) => (
|
||
<option key={status} value={status}>{copy.paymentStatusLabels[status]}</option>
|
||
))}
|
||
</select>
|
||
<button type="submit" className="btn-primary justify-center">{copy.applySearch}</button>
|
||
</form>
|
||
</div>
|
||
|
||
<label className="inline-flex items-center gap-2 text-sm font-medium text-slate-700">
|
||
<input type="checkbox" checked={outstandingOnly} onChange={(event) => { setPage(1); setOutstandingOnly(event.target.checked) }} className="h-4 w-4 rounded border-slate-300 text-blue-600" />
|
||
{copy.outstandingOnly}
|
||
</label>
|
||
|
||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||
<MetricCard label={copy.totalInvoiced} value={formatMoney(summary?.totalInvoiced ?? 0)} />
|
||
<MetricCard label={copy.collected} value={formatMoney(summary?.totalCollected ?? 0)} />
|
||
<MetricCard label={copy.outstanding} value={formatMoney(summary?.totalOutstanding ?? 0)} />
|
||
<MetricCard label={copy.depositsHeld} value={formatMoney(summary?.depositsHeld ?? 0)} />
|
||
<MetricCard label={copy.openInvoices} value={(summary?.openInvoiceCount ?? 0).toLocaleString(localeCode)} />
|
||
</div>
|
||
|
||
{error ? (
|
||
<div className="card p-6">
|
||
<p className="text-sm font-semibold text-rose-700">{error}</p>
|
||
<button type="button" onClick={() => loadBilling().catch((err) => setError(err.message ?? copy.failed))} className="mt-4 rounded-full border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">
|
||
{copy.retry}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="card overflow-hidden">
|
||
<div className="hidden overflow-x-auto lg:block">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b border-slate-200 bg-slate-50">
|
||
<th className="px-5 py-3 text-start text-xs font-medium uppercase text-slate-500">{copy.invoiceCustomer}</th>
|
||
<th className="px-5 py-3 text-start text-xs font-medium uppercase text-slate-500">{copy.vehicle}</th>
|
||
<th className="px-5 py-3 text-start text-xs font-medium uppercase text-slate-500">{copy.period}</th>
|
||
<th className="px-5 py-3 text-end text-xs font-medium uppercase text-slate-500">{copy.invoiceTotal}</th>
|
||
<th className="px-5 py-3 text-end text-xs font-medium uppercase text-slate-500">{copy.paid}</th>
|
||
<th className="px-5 py-3 text-end text-xs font-medium uppercase text-slate-500">{copy.balance}</th>
|
||
<th className="px-5 py-3 text-start text-xs font-medium uppercase text-slate-500">{copy.deposit}</th>
|
||
<th className="px-5 py-3 text-end text-xs font-medium uppercase text-slate-500">{copy.actions}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{loading ? (
|
||
<tr><td colSpan={8} className="px-5 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
|
||
) : rows.length === 0 ? (
|
||
<tr><td colSpan={8} className="px-5 py-10 text-center text-sm text-slate-400">{copy.empty}</td></tr>
|
||
) : rows.map((invoice) => (
|
||
<tr key={invoice.id}>
|
||
<td className="px-5 py-4 text-sm">
|
||
<p dir="ltr" className="font-semibold text-slate-900">{invoice.invoiceNumber ?? copy.unknownInvoice}</p>
|
||
<p className="mt-1 font-medium text-slate-700">{invoice.customer.firstName} {invoice.customer.lastName}</p>
|
||
<p dir="ltr" className="text-xs text-slate-500">{invoice.customer.email}</p>
|
||
</td>
|
||
<td className="px-5 py-4 text-sm text-slate-700">
|
||
<p>{invoice.vehicle.make} {invoice.vehicle.model}</p>
|
||
<p dir="ltr" className="text-xs text-slate-500">{invoice.vehicle.licensePlate}</p>
|
||
</td>
|
||
<td className="px-5 py-4 text-sm text-slate-600">
|
||
<p>{formatDate(invoice.rentalPeriod.startDate)}</p>
|
||
<p>{formatDate(invoice.rentalPeriod.endDate)}</p>
|
||
</td>
|
||
<td dir="ltr" className="px-5 py-4 text-end text-sm font-semibold text-slate-900">{formatMoney(invoice.invoiceTotal, invoice.currency)}</td>
|
||
<td dir="ltr" className="px-5 py-4 text-end text-sm font-semibold text-emerald-700">{formatMoney(invoice.invoicePaid, invoice.currency)}</td>
|
||
<td dir="ltr" className="px-5 py-4 text-end text-sm font-semibold text-orange-700">{formatMoney(invoice.invoiceBalanceDue, invoice.currency)}</td>
|
||
<td className="px-5 py-4 text-sm">
|
||
<StatusBadge label={copy.depositStatusLabels[invoice.depositStatus] ?? invoice.depositStatus} status={invoice.depositStatus} />
|
||
<p dir="ltr" className="mt-2 text-xs text-slate-500">{formatMoney(invoice.depositOutstanding, invoice.currency)} {copy.remaining}</p>
|
||
</td>
|
||
<td className="px-5 py-4 text-end text-sm">
|
||
<InvoiceActions invoice={invoice} copy={copy} onRecordPayment={openPaymentDialog} />
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="divide-y divide-slate-100 lg:hidden">
|
||
{loading ? (
|
||
<p className="p-6 text-center text-sm text-slate-400">{copy.loading}</p>
|
||
) : rows.length === 0 ? (
|
||
<p className="p-6 text-center text-sm text-slate-400">{copy.empty}</p>
|
||
) : rows.map((invoice) => (
|
||
<div key={invoice.id} className="space-y-4 p-5">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<p dir="ltr" className="font-semibold text-slate-900">{invoice.invoiceNumber ?? copy.unknownInvoice}</p>
|
||
<p className="mt-1 text-sm text-slate-700">{invoice.customer.firstName} {invoice.customer.lastName}</p>
|
||
<p className="text-xs text-slate-500">{invoice.vehicle.make} {invoice.vehicle.model}</p>
|
||
</div>
|
||
<StatusBadge label={copy.paymentStatusLabels[invoice.paymentStatus]} status={invoice.paymentStatus} />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||
<AmountBlock label={copy.invoiceTotal} value={formatMoney(invoice.invoiceTotal, invoice.currency)} />
|
||
<AmountBlock label={copy.balance} value={formatMoney(invoice.invoiceBalanceDue, invoice.currency)} />
|
||
<AmountBlock label={copy.paid} value={formatMoney(invoice.invoicePaid, invoice.currency)} />
|
||
<AmountBlock label={copy.deposit} value={formatMoney(invoice.depositOutstanding, invoice.currency)} />
|
||
</div>
|
||
<InvoiceActions invoice={invoice} copy={copy} onRecordPayment={openPaymentDialog} mobile />
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between border-t border-slate-100 px-5 py-4 text-sm">
|
||
<button type="button" disabled={currentPage <= 1 || loading} onClick={() => setPage((value) => Math.max(value - 1, 1))} className="rounded-full border border-slate-200 px-4 py-2 font-semibold text-slate-700 disabled:cursor-not-allowed disabled:opacity-50">
|
||
{copy.previous}
|
||
</button>
|
||
<span className="text-slate-500">{copy.pageLabel(currentPage, totalPages)}</span>
|
||
<button type="button" disabled={currentPage >= totalPages || loading} onClick={() => setPage((value) => value + 1)} className="rounded-full border border-slate-200 px-4 py-2 font-semibold text-slate-700 disabled:cursor-not-allowed disabled:opacity-50">
|
||
{copy.next}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{paymentInvoice ? (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/30 p-4 backdrop-blur-sm" onClick={(event) => {
|
||
if (event.target === event.currentTarget && !submittingPayment) setPaymentInvoice(null)
|
||
}}>
|
||
<div role="dialog" aria-modal="true" aria-labelledby="manual-payment-title" aria-describedby="manual-payment-description" className="w-full max-w-xl rounded-2xl bg-white p-6 shadow-2xl">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<h3 id="manual-payment-title" className="text-lg font-semibold text-slate-900">{copy.paymentDialogTitle}</h3>
|
||
<p id="manual-payment-description" className="mt-1 text-sm text-slate-500">{copy.paymentDialogDescription}</p>
|
||
</div>
|
||
<button type="button" aria-label={copy.cancel} onClick={() => setPaymentInvoice(null)} disabled={submittingPayment} className="rounded-full px-3 py-1 text-xl text-slate-400 hover:bg-slate-100 hover:text-slate-700">
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-5 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm">
|
||
<p className="font-semibold text-slate-900">{paymentInvoice.customer.firstName} {paymentInvoice.customer.lastName}</p>
|
||
<p dir="ltr" className="mt-1 text-slate-600">{paymentInvoice.invoiceNumber ?? copy.unknownInvoice}</p>
|
||
<p dir="ltr" className="mt-2 font-semibold text-slate-900">{copy.remaining}: {formatMoney(selectedRemaining, paymentInvoice.currency)}</p>
|
||
</div>
|
||
|
||
{paymentError ? <div role="alert" className="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">{paymentError}</div> : null}
|
||
|
||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||
<label className="space-y-1 text-sm font-medium text-slate-700">
|
||
<span>{copy.paymentTarget}</span>
|
||
<select value={paymentType} onChange={(event) => updatePaymentType(event.target.value as BillingPaymentType)} disabled={submittingPayment} className="input-field">
|
||
<option value="CHARGE" disabled={paymentInvoice.invoiceBalanceDue <= 0}>{copy.charge}</option>
|
||
<option value="DEPOSIT" disabled={paymentInvoice.depositOutstanding <= 0}>{copy.securityDeposit}</option>
|
||
</select>
|
||
</label>
|
||
<label className="space-y-1 text-sm font-medium text-slate-700">
|
||
<span>{copy.paymentMethod}</span>
|
||
<select value={paymentMethod} onChange={(event) => setPaymentMethod(event.target.value as ManualPaymentMethod)} disabled={submittingPayment} className="input-field">
|
||
{(Object.keys(copy.paymentMethodLabels) as ManualPaymentMethod[]).map((method) => (
|
||
<option key={method} value={method}>{copy.paymentMethodLabels[method]}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="space-y-1 text-sm font-medium text-slate-700">
|
||
<span>{copy.amount}</span>
|
||
<input type="number" min="0" step="0.01" value={paymentAmount} onChange={(event) => setPaymentAmount(event.target.value)} disabled={submittingPayment} className="input-field" />
|
||
</label>
|
||
<label className="space-y-1 text-sm font-medium text-slate-700">
|
||
<span>{copy.currency}</span>
|
||
<input value={paymentInvoice.currency} readOnly className="input-field bg-slate-50" />
|
||
</label>
|
||
<label className="space-y-1 text-sm font-medium text-slate-700">
|
||
<span>{copy.receivedAt}</span>
|
||
<input type="datetime-local" value={receivedAt} onChange={(event) => setReceivedAt(event.target.value)} disabled={submittingPayment} className="input-field" />
|
||
</label>
|
||
<label className="space-y-1 text-sm font-medium text-slate-700">
|
||
<span>{copy.reference}</span>
|
||
<input value={reference} onChange={(event) => setReference(event.target.value)} disabled={submittingPayment} className="input-field" />
|
||
</label>
|
||
<label className="space-y-1 text-sm font-medium text-slate-700 sm:col-span-2">
|
||
<span>{copy.note}</span>
|
||
<textarea value={note} onChange={(event) => setNote(event.target.value)} disabled={submittingPayment} className="input-field min-h-24" />
|
||
</label>
|
||
</div>
|
||
|
||
<div className="mt-6 flex flex-col-reverse gap-3 sm:flex-row">
|
||
<button type="button" onClick={() => setPaymentInvoice(null)} disabled={submittingPayment} className="flex-1 rounded-full border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 disabled:opacity-60">
|
||
{copy.cancel}
|
||
</button>
|
||
<button type="button" onClick={submitPayment} disabled={submittingPayment} className="btn-primary flex-1 justify-center disabled:cursor-not-allowed disabled:opacity-60">
|
||
{submittingPayment ? copy.saving : copy.save}
|
||
</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 dir="ltr" className="mt-2 text-2xl font-semibold text-slate-900">{value}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StatusBadge({ label, status }: { label: string; status: string }) {
|
||
return (
|
||
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_BADGE[status] ?? 'bg-slate-100 text-slate-700'}`}>
|
||
{label}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function AmountBlock({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div className="rounded-lg bg-slate-50 p-3">
|
||
<p className="text-xs font-medium uppercase text-slate-500">{label}</p>
|
||
<p dir="ltr" className="mt-1 font-semibold text-slate-900">{value}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function InvoiceActions({
|
||
invoice,
|
||
copy,
|
||
onRecordPayment,
|
||
mobile = false,
|
||
}: {
|
||
invoice: BillingInvoice
|
||
copy: any
|
||
onRecordPayment: (invoice: BillingInvoice) => void
|
||
mobile?: boolean
|
||
}) {
|
||
const canRecord = invoice.invoiceBalanceDue > 0 || invoice.depositOutstanding > 0
|
||
|
||
return (
|
||
<div className={`flex ${mobile ? 'flex-row flex-wrap' : 'flex-col items-end'} gap-2`}>
|
||
{canRecord ? (
|
||
<button type="button" onClick={() => onRecordPayment(invoice)} className="font-semibold text-emerald-700 hover:underline">
|
||
{copy.recordPayment}
|
||
</button>
|
||
) : (
|
||
<span className="text-xs text-slate-400">{copy.noPaymentDue}</span>
|
||
)}
|
||
<Link href={`/contracts/${invoice.reservationId}`} className="font-semibold text-blue-700 hover:underline">
|
||
{copy.openContract}
|
||
</Link>
|
||
<Link href={`/reservations/${invoice.reservationId}`} className="text-slate-600 hover:text-slate-900 hover:underline">
|
||
{copy.openBooking}
|
||
</Link>
|
||
</div>
|
||
)
|
||
}
|