'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 = { 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(null) const [invoices, setInvoices] = useState(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(null) const [paymentInvoice, setPaymentInvoice] = useState(null) const [paymentType, setPaymentType] = useState('CHARGE') const [paymentMethod, setPaymentMethod] = useState('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(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, depositStatusLabels: { NOT_REQUIRED: 'Not required', OUTSTANDING: 'Outstanding', PARTIALLY_COLLECTED: 'Partially collected', HELD: 'Held' } as Record, paymentMethodLabels: { CASH: 'Cash', CHECK: 'Check', BANK_TRANSFER: 'Bank transfer', CARD: 'Card', PAYPAL: 'PayPal', OTHER: 'Other' } as Record, }, 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, depositStatusLabels: { NOT_REQUIRED: 'Non requis', OUTSTANDING: 'À collecter', PARTIALLY_COLLECTED: 'Partiellement collecté', HELD: 'Détenu' } as Record, paymentMethodLabels: { CASH: 'Espèces', CHECK: 'Chèque', BANK_TRANSFER: 'Virement bancaire', CARD: 'Carte', PAYPAL: 'PayPal', OTHER: 'Autre' } as Record, }, 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, depositStatusLabels: { NOT_REQUIRED: 'غير مطلوب', OUTSTANDING: 'مستحق', PARTIALLY_COLLECTED: 'محصل جزئياً', HELD: 'محتجز' } as Record, paymentMethodLabels: { CASH: 'نقداً', CHECK: 'شيك', BANK_TRANSFER: 'تحويل بنكي', CARD: 'بطاقة', PAYPAL: 'PayPal', OTHER: 'أخرى' } as Record, }, }[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(`/billing/summary?${summaryParams.toString()}`), apiFetch(`/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 (

{copy.heading}

{copy.subtitle}

{ event.preventDefault() setPage(1) setSubmittedSearch(search) }} > setSearch(event.target.value)} placeholder={copy.search} className="input-field min-w-0 flex-1" />
{error ? (

{error}

) : (
{loading ? ( ) : rows.length === 0 ? ( ) : rows.map((invoice) => ( ))}
{copy.invoiceCustomer} {copy.vehicle} {copy.period} {copy.invoiceTotal} {copy.paid} {copy.balance} {copy.deposit} {copy.actions}
{copy.loading}
{copy.empty}

{invoice.invoiceNumber ?? copy.unknownInvoice}

{invoice.customer.firstName} {invoice.customer.lastName}

{invoice.customer.email}

{invoice.vehicle.make} {invoice.vehicle.model}

{invoice.vehicle.licensePlate}

{formatDate(invoice.rentalPeriod.startDate)}

{formatDate(invoice.rentalPeriod.endDate)}

{formatMoney(invoice.invoiceTotal, invoice.currency)} {formatMoney(invoice.invoicePaid, invoice.currency)} {formatMoney(invoice.invoiceBalanceDue, invoice.currency)}

{formatMoney(invoice.depositOutstanding, invoice.currency)} {copy.remaining}

{loading ? (

{copy.loading}

) : rows.length === 0 ? (

{copy.empty}

) : rows.map((invoice) => (

{invoice.invoiceNumber ?? copy.unknownInvoice}

{invoice.customer.firstName} {invoice.customer.lastName}

{invoice.vehicle.make} {invoice.vehicle.model}

))}
{copy.pageLabel(currentPage, totalPages)}
)} {paymentInvoice ? (
{ if (event.target === event.currentTarget && !submittingPayment) setPaymentInvoice(null) }}>

{copy.paymentDialogTitle}

{copy.paymentDialogDescription}

{paymentInvoice.customer.firstName} {paymentInvoice.customer.lastName}

{paymentInvoice.invoiceNumber ?? copy.unknownInvoice}

{copy.remaining}: {formatMoney(selectedRemaining, paymentInvoice.currency)}

{paymentError ?
{paymentError}
: null}