d7fb7b7a7b
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
1150 lines
48 KiB
TypeScript
1150 lines
48 KiB
TypeScript
'use client'
|
||
|
||
import type { ReactNode } from 'react'
|
||
import { useEffect, useMemo, useState, useTransition } from 'react'
|
||
import { useAdminI18n } from '@/components/I18nProvider'
|
||
import { ADMIN_API_BASE } from '@/lib/api'
|
||
|
||
interface BillingStats {
|
||
billingAccountCount: number
|
||
openInvoiceCount: number
|
||
pastDueInvoiceCount: number
|
||
paidInvoiceCount: number
|
||
accountsReceivableTotal: number
|
||
recognizedRevenueTotal: number
|
||
}
|
||
|
||
interface SubscriptionSummary {
|
||
id: string
|
||
plan: string
|
||
billingPeriod: string
|
||
status: string
|
||
currentPeriodEnd: string | null
|
||
cancelAtPeriodEnd: boolean
|
||
}
|
||
|
||
interface CompanySummary {
|
||
id: string
|
||
name: string
|
||
email: string
|
||
slug: string
|
||
status: string
|
||
subscription: SubscriptionSummary | null
|
||
}
|
||
|
||
interface CreditBalance {
|
||
id: string
|
||
currency: string
|
||
balanceAmount: number
|
||
}
|
||
|
||
interface InvoiceLineItem {
|
||
id?: string
|
||
type?: string
|
||
description: string
|
||
quantity: number
|
||
unitAmount: number
|
||
amount: number
|
||
currency: string
|
||
periodStart?: string | null
|
||
periodEnd?: string | null
|
||
}
|
||
|
||
interface PaymentAttempt {
|
||
id: string
|
||
status: string
|
||
amount: number
|
||
currency: string
|
||
providerPaymentId?: string | null
|
||
failureCode?: string | null
|
||
failureMessage?: string | null
|
||
attemptedAt: string
|
||
}
|
||
|
||
interface CreditNote {
|
||
id: string
|
||
amount: number
|
||
currency: string
|
||
reason: string
|
||
status: string
|
||
createdAt: string
|
||
}
|
||
|
||
interface Refund {
|
||
id: string
|
||
amount: number
|
||
currency: string
|
||
reason: string
|
||
status: string
|
||
createdAt: string
|
||
}
|
||
|
||
interface TaxRecord {
|
||
id: string
|
||
taxAmount: number
|
||
taxRate?: number | null
|
||
taxExempt: boolean
|
||
taxType?: string | null
|
||
}
|
||
|
||
interface BillingEvent {
|
||
id: string
|
||
eventType: string
|
||
source: string
|
||
createdAt: string
|
||
}
|
||
|
||
interface BillingInvoice {
|
||
id: string
|
||
invoiceNumber: string | null
|
||
invoiceType: string
|
||
status: string
|
||
currency: string
|
||
totalAmount: number
|
||
amountPaid: number
|
||
amountDue: number
|
||
invoiceDate: string | null
|
||
dueAt: string | null
|
||
paidAt: string | null
|
||
createdAt: string
|
||
isSubscriptionBlocking: boolean
|
||
lineItems: InvoiceLineItem[]
|
||
paymentAttempts: PaymentAttempt[]
|
||
creditNotes: CreditNote[]
|
||
refunds: Refund[]
|
||
taxRecords: TaxRecord[]
|
||
}
|
||
|
||
interface BillingAccountSummary {
|
||
id: string
|
||
companyId: string
|
||
legalName: string
|
||
billingEmail: string
|
||
invoiceTerms: string
|
||
netTermsDays: number
|
||
taxId?: string | null
|
||
taxExempt: boolean
|
||
dunningPaused: boolean
|
||
createdAt: string
|
||
company: CompanySummary
|
||
invoices: BillingInvoice[]
|
||
creditBalances: CreditBalance[]
|
||
openBalance: number
|
||
paidBalance: number
|
||
creditBalance: number
|
||
}
|
||
|
||
interface BillingAccountDetail extends BillingAccountSummary {
|
||
billingAddress?: Record<string, unknown> | null
|
||
paymentMethods: Array<{
|
||
id: string
|
||
type: string
|
||
label?: string | null
|
||
brand?: string | null
|
||
last4?: string | null
|
||
isDefault: boolean
|
||
isActive: boolean
|
||
}>
|
||
creditLedgerEntries: Array<{
|
||
id: string
|
||
type: string
|
||
amount: number
|
||
currency: string
|
||
reason: string
|
||
createdAt: string
|
||
}>
|
||
events: BillingEvent[]
|
||
}
|
||
|
||
interface PaginatedResponse<T> {
|
||
data: T[]
|
||
total: number
|
||
page: number
|
||
pageSize: number
|
||
totalPages: number
|
||
stats?: BillingStats
|
||
}
|
||
|
||
interface DraftLineItem {
|
||
type: string
|
||
description: string
|
||
quantity: number
|
||
unitAmount: number
|
||
}
|
||
|
||
const ACCOUNT_STATUS_COLORS: Record<string, string> = {
|
||
ACTIVE: 'text-emerald-300 bg-emerald-500/15',
|
||
TRIALING: 'text-sky-300 bg-sky-500/15',
|
||
PAYMENT_PENDING: 'text-amber-300 bg-amber-500/15',
|
||
PAST_DUE: 'text-rose-300 bg-rose-500/15',
|
||
SUSPENDED: 'text-red-300 bg-red-500/15',
|
||
CANCELLED: 'text-zinc-300 bg-zinc-700/60',
|
||
UNPAID: 'text-red-300 bg-red-500/15',
|
||
}
|
||
|
||
const INVOICE_STATUS_COLORS: Record<string, string> = {
|
||
DRAFT: 'text-zinc-300 bg-zinc-700/60',
|
||
OPEN: 'text-amber-300 bg-amber-500/15',
|
||
PAYMENT_PENDING: 'text-amber-300 bg-amber-500/15',
|
||
PARTIALLY_PAID: 'text-sky-300 bg-sky-500/15',
|
||
PAID: 'text-emerald-300 bg-emerald-500/15',
|
||
PAST_DUE: 'text-rose-300 bg-rose-500/15',
|
||
VOID: 'text-zinc-300 bg-zinc-700/60',
|
||
UNCOLLECTIBLE: 'text-red-200 bg-red-950/60',
|
||
REFUNDED: 'text-zinc-300 bg-zinc-700/60',
|
||
PARTIALLY_REFUNDED: 'text-zinc-300 bg-zinc-700/60',
|
||
}
|
||
|
||
const FIELD_CLASS =
|
||
'w-full rounded-xl border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 outline-none transition focus:border-blue-700'
|
||
|
||
const ACTION_PRIMARY_CLASS =
|
||
'rounded-xl bg-emerald-500 px-4 py-2 text-sm font-semibold text-black hover:bg-emerald-400'
|
||
|
||
const ACTION_SECONDARY_CLASS =
|
||
'rounded-xl border border-zinc-700 px-4 py-2 text-sm font-semibold text-zinc-200 hover:border-zinc-500'
|
||
|
||
const draftLineItem = (): DraftLineItem => ({
|
||
type: 'PROFESSIONAL_SERVICES',
|
||
description: '',
|
||
quantity: 1,
|
||
unitAmount: 0,
|
||
})
|
||
|
||
function money(amount: number, currency: string) {
|
||
return new Intl.NumberFormat('en-GB', {
|
||
style: 'currency',
|
||
currency,
|
||
minimumFractionDigits: 2,
|
||
}).format(amount / 100)
|
||
}
|
||
|
||
function dateLabel(value: string | null | undefined) {
|
||
if (!value) return '—'
|
||
return new Date(value).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||
}
|
||
|
||
function termsLabel(invoiceTerms: string, netTermsDays: number) {
|
||
if (invoiceTerms === 'DUE_ON_RECEIPT') return 'Due on receipt'
|
||
return `Net ${netTermsDays || invoiceTerms.replace('NET_', '')}`
|
||
}
|
||
|
||
export default function AdminBillingPage() {
|
||
const { language } = useAdminI18n()
|
||
const [isPending, startTransition] = useTransition()
|
||
|
||
const copy = {
|
||
en: {
|
||
title: 'Billing Console',
|
||
eyebrow: 'Platform Finance',
|
||
searchPlaceholder: 'Search company, billing contact, or slug',
|
||
all: 'All',
|
||
active: 'Active',
|
||
pastDue: 'Past Due',
|
||
suspended: 'Suspended',
|
||
cancelled: 'Cancelled',
|
||
accounts: 'Accounts',
|
||
receivables: 'Receivables',
|
||
openInvoices: 'Open invoices',
|
||
paidInvoices: 'Paid invoices',
|
||
revenue: 'Revenue',
|
||
billingAccounts: 'Billing Accounts',
|
||
selectAccount: 'Select a billing account to manage invoices, credits, dunning, and payment history.',
|
||
company: 'Company',
|
||
plan: 'Plan',
|
||
subscription: 'Subscription',
|
||
openBalance: 'Open balance',
|
||
credit: 'Credit',
|
||
dunning: 'Dunning',
|
||
accountDetails: 'Billing Account',
|
||
invoiceFeed: 'Invoices',
|
||
draftInvoice: 'Create Draft Invoice',
|
||
saveAccount: 'Save account',
|
||
pauseDunning: 'Pause dunning',
|
||
resumeDunning: 'Resume dunning',
|
||
createDraft: 'Create draft',
|
||
loading: 'Loading…',
|
||
noAccounts: 'No billing accounts found',
|
||
amountDue: 'Amount due',
|
||
amountPaid: 'Amount paid',
|
||
dueDate: 'Due date',
|
||
issueDate: 'Issue date',
|
||
actions: 'Actions',
|
||
lineItems: 'Line items',
|
||
payments: 'Payment attempts',
|
||
credits: 'Credits',
|
||
refunds: 'Refunds',
|
||
timeline: 'Timeline',
|
||
finalize: 'Finalize',
|
||
pay: 'Record payment',
|
||
retry: 'Retry payment',
|
||
void: 'Void',
|
||
writeoff: 'Write off',
|
||
refund: 'Refund',
|
||
creditNote: 'Credit note',
|
||
downloadPdf: 'PDF',
|
||
invoiceType: 'Invoice type',
|
||
blocking: 'Blocking invoice',
|
||
reason: 'Reason',
|
||
billingEmail: 'Billing email',
|
||
legalName: 'Legal name',
|
||
taxId: 'Tax ID',
|
||
taxExempt: 'Tax exempt',
|
||
invoiceTerms: 'Invoice terms',
|
||
selectedInvoice: 'Selected invoice',
|
||
noInvoices: 'No invoices yet',
|
||
},
|
||
fr: {
|
||
title: 'Console de facturation',
|
||
eyebrow: 'Finance plateforme',
|
||
searchPlaceholder: 'Rechercher une entreprise, un contact facturation ou un slug',
|
||
all: 'Tous',
|
||
active: 'Actifs',
|
||
pastDue: 'En retard',
|
||
suspended: 'Suspendus',
|
||
cancelled: 'Annulés',
|
||
accounts: 'Comptes',
|
||
receivables: 'Créances',
|
||
openInvoices: 'Factures ouvertes',
|
||
paidInvoices: 'Factures payées',
|
||
revenue: 'Revenu',
|
||
billingAccounts: 'Comptes de facturation',
|
||
selectAccount: 'Sélectionnez un compte de facturation pour gérer factures, crédits, relances et paiements.',
|
||
company: 'Entreprise',
|
||
plan: 'Plan',
|
||
subscription: 'Abonnement',
|
||
openBalance: 'Solde ouvert',
|
||
credit: 'Crédit',
|
||
dunning: 'Relance',
|
||
accountDetails: 'Compte de facturation',
|
||
invoiceFeed: 'Factures',
|
||
draftInvoice: 'Créer un brouillon',
|
||
saveAccount: 'Enregistrer',
|
||
pauseDunning: 'Mettre en pause',
|
||
resumeDunning: 'Reprendre',
|
||
createDraft: 'Créer le brouillon',
|
||
loading: 'Chargement…',
|
||
noAccounts: 'Aucun compte trouvé',
|
||
amountDue: 'Montant dû',
|
||
amountPaid: 'Montant payé',
|
||
dueDate: 'Échéance',
|
||
issueDate: 'Date émission',
|
||
actions: 'Actions',
|
||
lineItems: 'Lignes',
|
||
payments: 'Tentatives de paiement',
|
||
credits: 'Crédits',
|
||
refunds: 'Remboursements',
|
||
timeline: 'Historique',
|
||
finalize: 'Finaliser',
|
||
pay: 'Enregistrer paiement',
|
||
retry: 'Relancer paiement',
|
||
void: 'Annuler',
|
||
writeoff: 'Passer en perte',
|
||
refund: 'Rembourser',
|
||
creditNote: 'Avoir',
|
||
downloadPdf: 'PDF',
|
||
invoiceType: 'Type de facture',
|
||
blocking: 'Facture bloquante',
|
||
reason: 'Raison',
|
||
billingEmail: 'Email de facturation',
|
||
legalName: 'Raison sociale',
|
||
taxId: 'Identifiant fiscal',
|
||
taxExempt: 'Exonéré de taxe',
|
||
invoiceTerms: 'Conditions',
|
||
selectedInvoice: 'Facture sélectionnée',
|
||
noInvoices: 'Aucune facture',
|
||
},
|
||
ar: {
|
||
title: 'لوحة الفوترة',
|
||
eyebrow: 'مالية المنصة',
|
||
searchPlaceholder: 'ابحث عن الشركة أو جهة الفوترة أو الرابط',
|
||
all: 'الكل',
|
||
active: 'نشط',
|
||
pastDue: 'متأخر',
|
||
suspended: 'معلق',
|
||
cancelled: 'ملغى',
|
||
accounts: 'الحسابات',
|
||
receivables: 'الذمم',
|
||
openInvoices: 'الفواتير المفتوحة',
|
||
paidInvoices: 'الفواتير المدفوعة',
|
||
revenue: 'الإيراد',
|
||
billingAccounts: 'حسابات الفوترة',
|
||
selectAccount: 'اختر حساب فوترة لإدارة الفواتير والأرصدة ومحاولات الدفع.',
|
||
company: 'الشركة',
|
||
plan: 'الخطة',
|
||
subscription: 'الاشتراك',
|
||
openBalance: 'الرصيد المفتوح',
|
||
credit: 'الرصيد',
|
||
dunning: 'المتابعة',
|
||
accountDetails: 'حساب الفوترة',
|
||
invoiceFeed: 'الفواتير',
|
||
draftInvoice: 'إنشاء مسودة فاتورة',
|
||
saveAccount: 'حفظ',
|
||
pauseDunning: 'إيقاف المتابعة',
|
||
resumeDunning: 'استئناف المتابعة',
|
||
createDraft: 'إنشاء المسودة',
|
||
loading: 'جارٍ التحميل…',
|
||
noAccounts: 'لا توجد حسابات فوترة',
|
||
amountDue: 'المبلغ المستحق',
|
||
amountPaid: 'المبلغ المدفوع',
|
||
dueDate: 'تاريخ الاستحقاق',
|
||
issueDate: 'تاريخ الإصدار',
|
||
actions: 'الإجراءات',
|
||
lineItems: 'البنود',
|
||
payments: 'محاولات الدفع',
|
||
credits: 'الأرصدة',
|
||
refunds: 'الاستردادات',
|
||
timeline: 'السجل',
|
||
finalize: 'اعتماد',
|
||
pay: 'تسجيل الدفع',
|
||
retry: 'إعادة المحاولة',
|
||
void: 'إلغاء',
|
||
writeoff: 'شطب',
|
||
refund: 'استرداد',
|
||
creditNote: 'إشعار دائن',
|
||
downloadPdf: 'PDF',
|
||
invoiceType: 'نوع الفاتورة',
|
||
blocking: 'فاتورة حاجزة',
|
||
reason: 'السبب',
|
||
billingEmail: 'بريد الفوترة',
|
||
legalName: 'الاسم القانوني',
|
||
taxId: 'الرقم الضريبي',
|
||
taxExempt: 'معفى من الضريبة',
|
||
invoiceTerms: 'شروط الفاتورة',
|
||
selectedInvoice: 'الفاتورة المحددة',
|
||
noInvoices: 'لا توجد فواتير',
|
||
},
|
||
}[language]
|
||
|
||
const [accounts, setAccounts] = useState<BillingAccountSummary[]>([])
|
||
const [stats, setStats] = useState<BillingStats | null>(null)
|
||
const [search, setSearch] = useState('')
|
||
const [statusFilter, setStatusFilter] = useState('')
|
||
const [loadingList, setLoadingList] = useState(true)
|
||
const [listError, setListError] = useState<string | null>(null)
|
||
|
||
const [selectedCompanyId, setSelectedCompanyId] = useState<string | null>(null)
|
||
const [detail, setDetail] = useState<BillingAccountDetail | null>(null)
|
||
const [loadingDetail, setLoadingDetail] = useState(false)
|
||
const [detailError, setDetailError] = useState<string | null>(null)
|
||
|
||
const [accountForm, setAccountForm] = useState({
|
||
legalName: '',
|
||
billingEmail: '',
|
||
taxId: '',
|
||
taxExempt: false,
|
||
invoiceTerms: 'DUE_ON_RECEIPT',
|
||
netTermsDays: 0,
|
||
})
|
||
const [invoiceDraft, setInvoiceDraft] = useState({
|
||
invoiceType: 'MANUAL',
|
||
isSubscriptionBlocking: false,
|
||
adminReason: '',
|
||
lineItems: [draftLineItem()],
|
||
})
|
||
const [selectedInvoiceId, setSelectedInvoiceId] = useState<string | null>(null)
|
||
const [payAmount, setPayAmount] = useState('')
|
||
const [creditAmount, setCreditAmount] = useState('')
|
||
const [creditReason, setCreditReason] = useState('')
|
||
const [refundAmount, setRefundAmount] = useState('')
|
||
const [refundReason, setRefundReason] = useState('')
|
||
const [voidReason, setVoidReason] = useState('')
|
||
const [writeoffReason, setWriteoffReason] = useState('')
|
||
const [actionError, setActionError] = useState<string | null>(null)
|
||
const [actionMessage, setActionMessage] = useState<string | null>(null)
|
||
|
||
const filters = [
|
||
{ key: '', label: copy.all },
|
||
{ key: 'ACTIVE', label: copy.active },
|
||
{ key: 'PAST_DUE', label: copy.pastDue },
|
||
{ key: 'SUSPENDED', label: copy.suspended },
|
||
{ key: 'CANCELLED', label: copy.cancelled },
|
||
]
|
||
|
||
const selectedInvoice = useMemo(
|
||
() => detail?.invoices.find((invoice) => invoice.id === selectedInvoiceId) ?? detail?.invoices[0] ?? null,
|
||
[detail, selectedInvoiceId],
|
||
)
|
||
|
||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
|
||
...init,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(init?.headers ?? {}),
|
||
},
|
||
credentials: 'include',
|
||
cache: 'no-store',
|
||
})
|
||
if (res.headers.get('content-type')?.includes('application/pdf')) {
|
||
return await (res.blob() as unknown as Promise<T>)
|
||
}
|
||
const json = await res.json()
|
||
if (!res.ok) throw new Error(json?.message ?? `Request failed (${res.status})`)
|
||
return json.data as T
|
||
}
|
||
|
||
async function fetchAccounts(q?: string, status?: string) {
|
||
setLoadingList(true)
|
||
setListError(null)
|
||
try {
|
||
const params = new URLSearchParams({ pageSize: '100' })
|
||
if (q) params.set('q', q)
|
||
if (status) params.set('status', status)
|
||
const data = await api<PaginatedResponse<BillingAccountSummary>>(`/admin/billing?${params.toString()}`)
|
||
setAccounts(data.data)
|
||
setStats(data.stats ?? null)
|
||
setSelectedCompanyId((current) => current ?? data.data[0]?.company.id ?? null)
|
||
} catch (error: any) {
|
||
setListError(error.message)
|
||
} finally {
|
||
setLoadingList(false)
|
||
}
|
||
}
|
||
|
||
async function fetchDetail(companyId: string) {
|
||
setLoadingDetail(true)
|
||
setDetailError(null)
|
||
try {
|
||
const data = await api<BillingAccountDetail>(`/admin/billing/${companyId}`)
|
||
setDetail(data)
|
||
setAccountForm({
|
||
legalName: data.legalName,
|
||
billingEmail: data.billingEmail,
|
||
taxId: data.taxId ?? '',
|
||
taxExempt: data.taxExempt,
|
||
invoiceTerms: data.invoiceTerms,
|
||
netTermsDays: data.netTermsDays,
|
||
})
|
||
setSelectedInvoiceId(data.invoices[0]?.id ?? null)
|
||
} catch (error: any) {
|
||
setDetailError(error.message)
|
||
} finally {
|
||
setLoadingDetail(false)
|
||
}
|
||
}
|
||
|
||
async function refreshSelectedAccount() {
|
||
await fetchAccounts(search || undefined, statusFilter || undefined)
|
||
if (selectedCompanyId) await fetchDetail(selectedCompanyId)
|
||
}
|
||
|
||
useEffect(() => {
|
||
const id = setTimeout(() => {
|
||
fetchAccounts(search || undefined, statusFilter || undefined)
|
||
}, 200)
|
||
return () => clearTimeout(id)
|
||
}, [search, statusFilter])
|
||
|
||
useEffect(() => {
|
||
if (!selectedCompanyId) return
|
||
fetchDetail(selectedCompanyId)
|
||
}, [selectedCompanyId])
|
||
|
||
function setDraftLine(index: number, key: keyof DraftLineItem, value: string | number) {
|
||
setInvoiceDraft((current) => ({
|
||
...current,
|
||
lineItems: current.lineItems.map((item, itemIndex) =>
|
||
itemIndex === index ? { ...item, [key]: value } : item,
|
||
),
|
||
}))
|
||
}
|
||
|
||
async function saveBillingAccount() {
|
||
if (!detail) return
|
||
setActionError(null)
|
||
setActionMessage(null)
|
||
try {
|
||
await api(`/admin/billing/accounts/${detail.id}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({
|
||
legalName: accountForm.legalName,
|
||
billingEmail: accountForm.billingEmail,
|
||
taxId: accountForm.taxId || null,
|
||
taxExempt: accountForm.taxExempt,
|
||
invoiceTerms: accountForm.invoiceTerms,
|
||
netTermsDays: accountForm.netTermsDays,
|
||
}),
|
||
})
|
||
setActionMessage('Billing account updated.')
|
||
await refreshSelectedAccount()
|
||
} catch (error: any) {
|
||
setActionError(error.message)
|
||
}
|
||
}
|
||
|
||
async function toggleDunning(paused: boolean) {
|
||
if (!detail) return
|
||
setActionError(null)
|
||
setActionMessage(null)
|
||
try {
|
||
await api(`/admin/billing/accounts/${detail.id}/${paused ? 'pause-dunning' : 'resume-dunning'}`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({}),
|
||
})
|
||
setActionMessage(paused ? 'Dunning paused.' : 'Dunning resumed.')
|
||
await refreshSelectedAccount()
|
||
} catch (error: any) {
|
||
setActionError(error.message)
|
||
}
|
||
}
|
||
|
||
async function createDraftInvoice() {
|
||
if (!detail) return
|
||
setActionError(null)
|
||
setActionMessage(null)
|
||
try {
|
||
await api(`/admin/billing/accounts/${detail.id}/invoices`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
invoiceType: invoiceDraft.invoiceType,
|
||
isSubscriptionBlocking: invoiceDraft.isSubscriptionBlocking,
|
||
adminReason: invoiceDraft.adminReason || null,
|
||
lineItems: invoiceDraft.lineItems.map((item) => ({
|
||
...item,
|
||
unitAmount: Number(item.unitAmount),
|
||
quantity: Number(item.quantity),
|
||
})),
|
||
}),
|
||
})
|
||
setInvoiceDraft({
|
||
invoiceType: 'MANUAL',
|
||
isSubscriptionBlocking: false,
|
||
adminReason: '',
|
||
lineItems: [draftLineItem()],
|
||
})
|
||
setActionMessage('Draft invoice created.')
|
||
await refreshSelectedAccount()
|
||
} catch (error: any) {
|
||
setActionError(error.message)
|
||
}
|
||
}
|
||
|
||
async function runInvoiceAction(path: string, body?: Record<string, unknown>) {
|
||
if (!selectedInvoice) return
|
||
setActionError(null)
|
||
setActionMessage(null)
|
||
try {
|
||
await api(`/admin/billing/invoices/${selectedInvoice.id}/${path}`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(body ?? {}),
|
||
})
|
||
setActionMessage('Invoice action completed.')
|
||
await refreshSelectedAccount()
|
||
} catch (error: any) {
|
||
setActionError(error.message)
|
||
}
|
||
}
|
||
|
||
async function downloadPdf() {
|
||
if (!selectedInvoice) return
|
||
setActionError(null)
|
||
try {
|
||
const blob = await api<Blob>(`/admin/billing/invoices/${selectedInvoice.id}/pdf`)
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `${selectedInvoice.invoiceNumber ?? selectedInvoice.id}.pdf`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
} catch (error: any) {
|
||
setActionError(error.message)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="rdg-page-heading flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||
<div>
|
||
<p className="rdg-page-kicker">{copy.eyebrow}</p>
|
||
<h1 className="mt-2 text-3xl font-black text-zinc-50">{copy.title}</h1>
|
||
</div>
|
||
<div className="w-full max-w-md">
|
||
<input
|
||
value={search}
|
||
onChange={(event) => setSearch(event.target.value)}
|
||
placeholder={copy.searchPlaceholder}
|
||
className="w-full rounded-2xl border border-zinc-800 bg-zinc-950 px-4 py-3 text-sm text-zinc-100 outline-none transition focus:border-blue-700"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{stats && (
|
||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||
<StatCard label={copy.accounts} value={String(stats.billingAccountCount)} tone="text-zinc-50" />
|
||
<StatCard label={copy.receivables} value={money(stats.accountsReceivableTotal, 'MAD')} tone="text-amber-300" />
|
||
<StatCard label={copy.openInvoices} value={String(stats.openInvoiceCount)} tone="text-amber-300" />
|
||
<StatCard label={copy.paidInvoices} value={String(stats.paidInvoiceCount)} tone="text-emerald-300" />
|
||
<StatCard label={copy.revenue} value={money(stats.recognizedRevenueTotal, 'MAD')} tone="text-sky-300" />
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
{filters.map((filter) => (
|
||
<button
|
||
key={filter.key}
|
||
onClick={() => startTransition(() => setStatusFilter(filter.key))}
|
||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||
statusFilter === filter.key
|
||
? 'bg-emerald-500 text-black'
|
||
: 'bg-zinc-900 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
|
||
}`}
|
||
>
|
||
{filter.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid gap-6 xl:grid-cols-[1.15fr,1.85fr]">
|
||
<section className="panel overflow-hidden">
|
||
<div className="border-b border-zinc-800 px-5 py-4">
|
||
<h2 className="text-sm font-semibold text-zinc-100">{copy.billingAccounts}</h2>
|
||
</div>
|
||
<div className="max-h-[76vh] overflow-y-auto">
|
||
{loadingList ? (
|
||
<div className="px-5 py-10 text-sm text-zinc-500">{copy.loading}</div>
|
||
) : listError ? (
|
||
<div className="px-5 py-10 text-sm text-red-400">{listError}</div>
|
||
) : accounts.length === 0 ? (
|
||
<div className="px-5 py-10 text-sm text-zinc-500">{copy.noAccounts}</div>
|
||
) : (
|
||
<div className="divide-y divide-zinc-800">
|
||
{accounts.map((account) => {
|
||
const isSelected = selectedCompanyId === account.company.id
|
||
const subscription = account.company.subscription
|
||
return (
|
||
<button
|
||
key={account.id}
|
||
onClick={() => setSelectedCompanyId(account.company.id)}
|
||
className={`w-full px-5 py-4 text-left transition ${isSelected ? 'bg-emerald-500/8' : 'hover:bg-zinc-900/80'}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<p className="font-semibold text-zinc-100">{account.company.name}</p>
|
||
<p className="mt-1 text-xs text-zinc-500">{account.billingEmail}</p>
|
||
</div>
|
||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${ACCOUNT_STATUS_COLORS[subscription?.status ?? 'CANCELLED'] ?? 'text-zinc-300 bg-zinc-700/60'}`}>
|
||
{subscription?.status ?? 'NO_SUBSCRIPTION'}
|
||
</span>
|
||
</div>
|
||
<div className="mt-4 grid grid-cols-3 gap-3 text-xs">
|
||
<Metric label={copy.plan} value={subscription?.plan ?? '—'} />
|
||
<Metric label={copy.openBalance} value={money(account.openBalance, account.creditBalances[0]?.currency ?? 'MAD')} />
|
||
<Metric label={copy.credit} value={money(account.creditBalance, account.creditBalances[0]?.currency ?? 'MAD')} />
|
||
</div>
|
||
<div className="mt-3 flex items-center justify-between text-[11px] text-zinc-500">
|
||
<span>{termsLabel(account.invoiceTerms, account.netTermsDays)}</span>
|
||
<span>{account.dunningPaused ? `${copy.dunning}: paused` : `${copy.dunning}: live`}</span>
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="space-y-6">
|
||
{!selectedCompanyId ? (
|
||
<div className="panel px-6 py-10 text-sm text-zinc-500">{copy.selectAccount}</div>
|
||
) : loadingDetail && !detail ? (
|
||
<div className="panel px-6 py-10 text-sm text-zinc-500">{copy.loading}</div>
|
||
) : detailError ? (
|
||
<div className="panel px-6 py-10 text-sm text-red-400">{detailError}</div>
|
||
) : detail ? (
|
||
<>
|
||
<section className="panel p-5">
|
||
<div className="flex flex-col gap-4 border-b border-zinc-800 pb-5 lg:flex-row lg:items-start lg:justify-between">
|
||
<div>
|
||
<p className="text-xs uppercase tracking-[0.2em] text-zinc-500">{copy.accountDetails}</p>
|
||
<h2 className="mt-2 text-2xl font-bold text-zinc-50">{detail.company.name}</h2>
|
||
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||
<span className={`rounded-full px-2 py-1 font-semibold ${ACCOUNT_STATUS_COLORS[detail.company.subscription?.status ?? 'CANCELLED'] ?? 'text-zinc-300 bg-zinc-700/60'}`}>
|
||
{detail.company.subscription?.status ?? 'NO_SUBSCRIPTION'}
|
||
</span>
|
||
<span className="rounded-full bg-zinc-900 px-2 py-1 text-zinc-300">
|
||
{detail.company.subscription?.plan ?? 'No plan'} / {detail.company.subscription?.billingPeriod ?? '—'}
|
||
</span>
|
||
<span className="rounded-full bg-zinc-900 px-2 py-1 text-zinc-300">
|
||
{termsLabel(detail.invoiceTerms, detail.netTermsDays)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3 text-right text-sm sm:grid-cols-4">
|
||
<Metric label={copy.openBalance} value={money(detail.openBalance, detail.creditBalances[0]?.currency ?? 'MAD')} />
|
||
<Metric label={copy.credit} value={money(detail.creditBalance, detail.creditBalances[0]?.currency ?? 'MAD')} />
|
||
<Metric label={copy.amountPaid} value={money(detail.paidBalance, detail.creditBalances[0]?.currency ?? 'MAD')} />
|
||
<Metric label={copy.dunning} value={detail.dunningPaused ? 'Paused' : 'Live'} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||
<Field label={copy.legalName}>
|
||
<input
|
||
value={accountForm.legalName}
|
||
onChange={(event) => setAccountForm((current) => ({ ...current, legalName: event.target.value }))}
|
||
className={FIELD_CLASS}
|
||
/>
|
||
</Field>
|
||
<Field label={copy.billingEmail}>
|
||
<input
|
||
value={accountForm.billingEmail}
|
||
onChange={(event) => setAccountForm((current) => ({ ...current, billingEmail: event.target.value }))}
|
||
className={FIELD_CLASS}
|
||
/>
|
||
</Field>
|
||
<Field label={copy.taxId}>
|
||
<input
|
||
value={accountForm.taxId}
|
||
onChange={(event) => setAccountForm((current) => ({ ...current, taxId: event.target.value }))}
|
||
className={FIELD_CLASS}
|
||
/>
|
||
</Field>
|
||
<Field label={copy.invoiceTerms}>
|
||
<select
|
||
value={accountForm.invoiceTerms}
|
||
onChange={(event) => setAccountForm((current) => ({
|
||
...current,
|
||
invoiceTerms: event.target.value,
|
||
netTermsDays: event.target.value === 'DUE_ON_RECEIPT' ? 0 : current.netTermsDays || Number(event.target.value.replace('NET_', '')),
|
||
}))}
|
||
className={FIELD_CLASS}
|
||
>
|
||
<option value="DUE_ON_RECEIPT">Due on receipt</option>
|
||
<option value="NET_7">Net 7</option>
|
||
<option value="NET_15">Net 15</option>
|
||
<option value="NET_30">Net 30</option>
|
||
<option value="NET_45">Net 45</option>
|
||
<option value="NET_60">Net 60</option>
|
||
</select>
|
||
</Field>
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||
<label className="flex items-center gap-2 text-sm text-zinc-300">
|
||
<input
|
||
type="checkbox"
|
||
checked={accountForm.taxExempt}
|
||
onChange={(event) => setAccountForm((current) => ({ ...current, taxExempt: event.target.checked }))}
|
||
className="rounded border-zinc-700 bg-zinc-950 text-emerald-500"
|
||
/>
|
||
{copy.taxExempt}
|
||
</label>
|
||
<button onClick={saveBillingAccount} className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-semibold text-black hover:bg-emerald-400">
|
||
{copy.saveAccount}
|
||
</button>
|
||
<button
|
||
onClick={() => toggleDunning(!detail.dunningPaused)}
|
||
className="rounded-xl border border-zinc-700 px-4 py-2 text-sm font-semibold text-zinc-200 hover:border-zinc-500"
|
||
>
|
||
{detail.dunningPaused ? copy.resumeDunning : copy.pauseDunning}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="panel p-5">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-semibold text-zinc-100">{copy.invoiceFeed}</h3>
|
||
{isPending || loadingDetail ? <span className="text-xs text-zinc-500">{copy.loading}</span> : null}
|
||
</div>
|
||
|
||
{detail.invoices.length === 0 ? (
|
||
<div className="mt-4 text-sm text-zinc-500">{copy.noInvoices}</div>
|
||
) : (
|
||
<div className="mt-4 grid gap-4 xl:grid-cols-[1.2fr,0.8fr]">
|
||
<div className="overflow-hidden rounded-2xl border border-zinc-800">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-zinc-950/80">
|
||
<tr className="text-left text-xs uppercase tracking-[0.16em] text-zinc-500">
|
||
<th className="px-4 py-3">Invoice</th>
|
||
<th className="px-4 py-3">{copy.amountDue}</th>
|
||
<th className="px-4 py-3">{copy.dueDate}</th>
|
||
<th className="px-4 py-3">{copy.actions}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-zinc-800">
|
||
{detail.invoices.map((invoice) => (
|
||
<tr
|
||
key={invoice.id}
|
||
className={`cursor-pointer transition hover:bg-zinc-900/80 ${selectedInvoice?.id === invoice.id ? 'bg-emerald-500/8' : ''}`}
|
||
onClick={() => setSelectedInvoiceId(invoice.id)}
|
||
>
|
||
<td className="px-4 py-3">
|
||
<div>
|
||
<p className="font-medium text-zinc-100">{invoice.invoiceNumber ?? invoice.invoiceType}</p>
|
||
<p className="mt-1 text-xs text-zinc-500">{dateLabel(invoice.invoiceDate ?? invoice.createdAt)}</p>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-zinc-200">{money(invoice.amountDue, invoice.currency)}</td>
|
||
<td className="px-4 py-3 text-zinc-400">{dateLabel(invoice.dueAt)}</td>
|
||
<td className="px-4 py-3">
|
||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${INVOICE_STATUS_COLORS[invoice.status] ?? 'text-zinc-300 bg-zinc-700/60'}`}>
|
||
{invoice.status}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{selectedInvoice && (
|
||
<div className="space-y-4 rounded-2xl border border-zinc-800 bg-zinc-950/80 p-4">
|
||
<div>
|
||
<p className="text-xs uppercase tracking-[0.16em] text-zinc-500">{copy.selectedInvoice}</p>
|
||
<div className="mt-2 flex items-center justify-between gap-3">
|
||
<div>
|
||
<h4 className="text-lg font-semibold text-zinc-50">{selectedInvoice.invoiceNumber ?? selectedInvoice.invoiceType}</h4>
|
||
<p className="mt-1 text-sm text-zinc-400">
|
||
{copy.issueDate}: {dateLabel(selectedInvoice.invoiceDate ?? selectedInvoice.createdAt)}
|
||
</p>
|
||
</div>
|
||
<span className={`rounded-full px-2 py-1 text-xs font-semibold ${INVOICE_STATUS_COLORS[selectedInvoice.status] ?? 'text-zinc-300 bg-zinc-700/60'}`}>
|
||
{selectedInvoice.status}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||
<Metric label={copy.amountDue} value={money(selectedInvoice.amountDue, selectedInvoice.currency)} />
|
||
<Metric label={copy.amountPaid} value={money(selectedInvoice.amountPaid, selectedInvoice.currency)} />
|
||
</div>
|
||
|
||
<div>
|
||
<p className="mb-2 text-xs uppercase tracking-[0.16em] text-zinc-500">{copy.lineItems}</p>
|
||
<div className="space-y-2">
|
||
{selectedInvoice.lineItems.map((item, index) => (
|
||
<div key={`${selectedInvoice.id}-${index}`} className="rounded-xl border border-zinc-800 px-3 py-2">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<p className="text-sm text-zinc-100">{item.description}</p>
|
||
<p className="text-xs text-zinc-500">
|
||
{item.quantity} × {money(item.unitAmount, item.currency)}
|
||
</p>
|
||
</div>
|
||
<p className="text-sm font-medium text-zinc-200">{money(item.amount, item.currency)}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
{selectedInvoice.status === 'DRAFT' ? (
|
||
<button onClick={() => runInvoiceAction('finalize')} className={ACTION_PRIMARY_CLASS}>{copy.finalize}</button>
|
||
) : null}
|
||
{['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(selectedInvoice.status) ? (
|
||
<>
|
||
<div className="grid grid-cols-[1fr,auto] gap-2">
|
||
<input
|
||
value={payAmount}
|
||
onChange={(event) => setPayAmount(event.target.value)}
|
||
placeholder="Amount in cents"
|
||
className={FIELD_CLASS}
|
||
/>
|
||
<button
|
||
onClick={() => runInvoiceAction('pay', payAmount ? { amount: Number(payAmount) } : {})}
|
||
className={ACTION_PRIMARY_CLASS}
|
||
>
|
||
{copy.pay}
|
||
</button>
|
||
</div>
|
||
<button onClick={() => runInvoiceAction('retry-payment')} className={ACTION_SECONDARY_CLASS}>{copy.retry}</button>
|
||
<div className="grid gap-2 sm:grid-cols-[1fr,auto]">
|
||
<input value={voidReason} onChange={(event) => setVoidReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} />
|
||
<button onClick={() => runInvoiceAction('void', { reason: voidReason })} className={ACTION_SECONDARY_CLASS}>{copy.void}</button>
|
||
</div>
|
||
<div className="grid gap-2 sm:grid-cols-[1fr,auto]">
|
||
<input value={writeoffReason} onChange={(event) => setWriteoffReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} />
|
||
<button onClick={() => runInvoiceAction('uncollectible', { reason: writeoffReason })} className={ACTION_SECONDARY_CLASS}>{copy.writeoff}</button>
|
||
</div>
|
||
<div className="grid gap-2 sm:grid-cols-[120px,1fr,auto]">
|
||
<input value={creditAmount} onChange={(event) => setCreditAmount(event.target.value)} placeholder="Cents" className={FIELD_CLASS} />
|
||
<input value={creditReason} onChange={(event) => setCreditReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} />
|
||
<button onClick={() => runInvoiceAction('credit-notes', { amount: Number(creditAmount), reason: creditReason })} className={ACTION_SECONDARY_CLASS}>{copy.creditNote}</button>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
{['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(selectedInvoice.status) ? (
|
||
<div className="grid gap-2 sm:grid-cols-[120px,1fr,auto]">
|
||
<input value={refundAmount} onChange={(event) => setRefundAmount(event.target.value)} placeholder="Cents" className={FIELD_CLASS} />
|
||
<input value={refundReason} onChange={(event) => setRefundReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} />
|
||
<button onClick={() => runInvoiceAction('refunds', { amount: Number(refundAmount), reason: refundReason })} className={ACTION_SECONDARY_CLASS}>{copy.refund}</button>
|
||
</div>
|
||
) : null}
|
||
{selectedInvoice.invoiceNumber ? (
|
||
<button onClick={downloadPdf} className={ACTION_SECONDARY_CLASS}>{copy.downloadPdf}</button>
|
||
) : null}
|
||
</div>
|
||
|
||
<DetailList title={copy.payments} items={selectedInvoice.paymentAttempts.map((attempt) => ({
|
||
id: attempt.id,
|
||
primary: `${attempt.status} · ${money(attempt.amount, attempt.currency)}`,
|
||
secondary: `${dateLabel(attempt.attemptedAt)}${attempt.failureMessage ? ` · ${attempt.failureMessage}` : ''}`,
|
||
}))} />
|
||
<DetailList title={copy.credits} items={selectedInvoice.creditNotes.map((note) => ({
|
||
id: note.id,
|
||
primary: `${money(note.amount, note.currency)} · ${note.status}`,
|
||
secondary: note.reason,
|
||
}))} />
|
||
<DetailList title={copy.refunds} items={selectedInvoice.refunds.map((refund) => ({
|
||
id: refund.id,
|
||
primary: `${money(refund.amount, refund.currency)} · ${refund.status}`,
|
||
secondary: refund.reason,
|
||
}))} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="panel p-5">
|
||
<h3 className="text-sm font-semibold text-zinc-100">{copy.draftInvoice}</h3>
|
||
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr,1fr,auto]">
|
||
<Field label={copy.invoiceType}>
|
||
<select
|
||
value={invoiceDraft.invoiceType}
|
||
onChange={(event) => setInvoiceDraft((current) => ({ ...current, invoiceType: event.target.value }))}
|
||
className={FIELD_CLASS}
|
||
>
|
||
<option value="MANUAL">Manual</option>
|
||
<option value="ONE_TIME">One time</option>
|
||
<option value="SUBSCRIPTION_RENEWAL">Subscription renewal</option>
|
||
<option value="SUBSCRIPTION_INITIAL">Subscription initial</option>
|
||
<option value="TRIAL_CONVERSION">Trial conversion</option>
|
||
<option value="CORRECTION">Correction</option>
|
||
</select>
|
||
</Field>
|
||
<Field label={copy.reason}>
|
||
<input
|
||
value={invoiceDraft.adminReason}
|
||
onChange={(event) => setInvoiceDraft((current) => ({ ...current, adminReason: event.target.value }))}
|
||
className={FIELD_CLASS}
|
||
/>
|
||
</Field>
|
||
<label className="flex items-end gap-2 text-sm text-zinc-300">
|
||
<input
|
||
type="checkbox"
|
||
checked={invoiceDraft.isSubscriptionBlocking}
|
||
onChange={(event) => setInvoiceDraft((current) => ({ ...current, isSubscriptionBlocking: event.target.checked }))}
|
||
className="mb-3 rounded border-zinc-700 bg-zinc-950 text-emerald-500"
|
||
/>
|
||
<span className="mb-2">{copy.blocking}</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="mt-4 space-y-3">
|
||
{invoiceDraft.lineItems.map((item, index) => (
|
||
<div key={index} className="grid gap-3 rounded-2xl border border-zinc-800 bg-zinc-950/60 p-4 lg:grid-cols-[180px,1fr,100px,140px,auto]">
|
||
<select value={item.type} onChange={(event) => setDraftLine(index, 'type', event.target.value)} className={FIELD_CLASS}>
|
||
<option value="PROFESSIONAL_SERVICES">Professional services</option>
|
||
<option value="SETUP_FEE">Setup fee</option>
|
||
<option value="SUBSCRIPTION_FEE">Subscription fee</option>
|
||
<option value="MANUAL_ADJUSTMENT">Manual adjustment</option>
|
||
<option value="DISCOUNT">Discount</option>
|
||
</select>
|
||
<input value={item.description} onChange={(event) => setDraftLine(index, 'description', event.target.value)} placeholder="Description" className={FIELD_CLASS} />
|
||
<input type="number" min={1} value={item.quantity} onChange={(event) => setDraftLine(index, 'quantity', Number(event.target.value))} className={FIELD_CLASS} />
|
||
<input type="number" value={item.unitAmount} onChange={(event) => setDraftLine(index, 'unitAmount', Number(event.target.value))} placeholder="Unit amount (cents)" className={FIELD_CLASS} />
|
||
<button
|
||
onClick={() => setInvoiceDraft((current) => ({
|
||
...current,
|
||
lineItems: current.lineItems.length === 1 ? current.lineItems : current.lineItems.filter((_, lineIndex) => lineIndex !== index),
|
||
}))}
|
||
className="rounded-xl border border-zinc-700 px-3 py-2 text-sm text-zinc-300 hover:border-zinc-500"
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-wrap gap-3">
|
||
<button
|
||
onClick={() => setInvoiceDraft((current) => ({ ...current, lineItems: [...current.lineItems, draftLineItem()] }))}
|
||
className="rounded-xl border border-zinc-700 px-4 py-2 text-sm font-semibold text-zinc-200 hover:border-zinc-500"
|
||
>
|
||
Add line
|
||
</button>
|
||
<button onClick={createDraftInvoice} className={ACTION_PRIMARY_CLASS}>{copy.createDraft}</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="panel p-5">
|
||
<div className="grid gap-5 xl:grid-cols-2">
|
||
<DetailList title={copy.timeline} items={detail.events.map((event) => ({
|
||
id: event.id,
|
||
primary: event.eventType,
|
||
secondary: `${event.source} · ${dateLabel(event.createdAt)}`,
|
||
}))} />
|
||
<DetailList title={copy.credits} items={detail.creditLedgerEntries.map((entry) => ({
|
||
id: entry.id,
|
||
primary: `${money(entry.amount, entry.currency)} · ${entry.type}`,
|
||
secondary: entry.reason,
|
||
}))} />
|
||
</div>
|
||
</section>
|
||
</>
|
||
) : null}
|
||
</section>
|
||
</div>
|
||
|
||
{actionError ? <div className="panel p-4 text-sm text-red-400">{actionError}</div> : null}
|
||
{actionMessage ? <div className="panel p-4 text-sm text-emerald-300">{actionMessage}</div> : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StatCard({ label, value, tone }: { label: string; value: string; tone: string }) {
|
||
return (
|
||
<div className="panel p-4">
|
||
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">{label}</p>
|
||
<p className={`mt-2 text-2xl font-black ${tone}`}>{value}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Metric({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div>
|
||
<p className="text-[11px] uppercase tracking-[0.16em] text-zinc-500">{label}</p>
|
||
<p className="mt-1 text-sm font-semibold text-zinc-100">{value}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||
return (
|
||
<label className="block">
|
||
<span className="mb-2 block text-xs uppercase tracking-[0.16em] text-zinc-500">{label}</span>
|
||
{children}
|
||
</label>
|
||
)
|
||
}
|
||
|
||
function DetailList({
|
||
title,
|
||
items,
|
||
}: {
|
||
title: string
|
||
items: Array<{ id: string; primary: string; secondary: string }>
|
||
}) {
|
||
return (
|
||
<div>
|
||
<p className="mb-2 text-xs uppercase tracking-[0.16em] text-zinc-500">{title}</p>
|
||
{items.length === 0 ? (
|
||
<div className="rounded-xl border border-zinc-800 px-3 py-3 text-sm text-zinc-500">—</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{items.map((item) => (
|
||
<div key={item.id} className="rounded-xl border border-zinc-800 px-3 py-3">
|
||
<p className="text-sm text-zinc-100">{item.primary}</p>
|
||
<p className="mt-1 text-xs text-zinc-500">{item.secondary}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|