Files
carmanagement/apps/dashboard/src/app/(dashboard)/billing/page.tsx
T
root 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
fix setting and add rental policies
2026-07-20 22:47:52 -04:00

654 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 dabonnement 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>
)
}