fix first online resevation
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
interface Company {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
slug: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
id: string
|
||||
amount: number
|
||||
currency: string
|
||||
status: string
|
||||
paymentProvider: string
|
||||
amanpayTransactionId?: string | null
|
||||
paypalCaptureId?: string | null
|
||||
paidAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function buildInvoiceNumber(invoiceId: string, createdAt: string) {
|
||||
const year = new Date(createdAt).getFullYear()
|
||||
const short = invoiceId.slice(-6).toUpperCase()
|
||||
return `INV-${year}-${short}`
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
id: string
|
||||
companyId: string
|
||||
company: Company
|
||||
plan: string
|
||||
billingPeriod: string
|
||||
status: string
|
||||
currency: string
|
||||
currentPeriodEnd: string | null
|
||||
cancelAtPeriodEnd: boolean
|
||||
invoices: Invoice[]
|
||||
_count: { invoices: number }
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
mrr: number
|
||||
activeCount: number
|
||||
trialingCount: number
|
||||
pastDueCount: number
|
||||
cancelledCount: number
|
||||
}
|
||||
|
||||
const SUB_STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
|
||||
TRIALING: 'text-sky-400 bg-sky-900/30',
|
||||
PAST_DUE: 'text-amber-400 bg-amber-900/30',
|
||||
CANCELLED: 'text-zinc-400 bg-zinc-800',
|
||||
UNPAID: 'text-red-400 bg-red-900/30',
|
||||
}
|
||||
|
||||
const INVOICE_STATUS_COLORS: Record<string, string> = {
|
||||
PAID: 'text-emerald-400 bg-emerald-900/30',
|
||||
PENDING: 'text-amber-400 bg-amber-900/30',
|
||||
FAILED: 'text-red-400 bg-red-900/30',
|
||||
REFUNDED: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
const PLAN_COLORS: Record<string, string> = {
|
||||
STARTER: 'text-zinc-300',
|
||||
GROWTH: 'text-sky-400',
|
||||
PRO: 'text-violet-400',
|
||||
}
|
||||
|
||||
function fmt(amount: number, currency: string) {
|
||||
return `${(amount / 100).toFixed(2)} ${currency}`
|
||||
}
|
||||
|
||||
function fmtDate(iso: string | null) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
export default function AdminBillingPage() {
|
||||
const { language } = useAdminI18n()
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Billing',
|
||||
eyebrow: 'Platform',
|
||||
mrr: 'MRR (MAD)',
|
||||
active: 'Active',
|
||||
trialing: 'Trialing',
|
||||
pastDue: 'Past Due',
|
||||
cancelled: 'Cancelled',
|
||||
company: 'Company',
|
||||
plan: 'Plan',
|
||||
period: 'Period',
|
||||
status: 'Status',
|
||||
currency: 'Currency',
|
||||
nextRenewal: 'Next Renewal',
|
||||
invoices: 'Invoices',
|
||||
actions: 'Actions',
|
||||
viewInvoices: 'Invoices',
|
||||
loading: 'Loading…',
|
||||
empty: 'No subscriptions found',
|
||||
filterAll: 'All',
|
||||
filterActive: 'Active',
|
||||
filterTrialing: 'Trialing',
|
||||
filterPastDue: 'Past Due',
|
||||
filterCancelled: 'Cancelled',
|
||||
closeModal: 'Close',
|
||||
invoicesFor: 'Invoices for',
|
||||
noInvoices: 'No invoices found',
|
||||
amount: 'Amount',
|
||||
invoiceStatus: 'Status',
|
||||
provider: 'Provider',
|
||||
paidAt: 'Paid At',
|
||||
date: 'Date',
|
||||
invoiceNo: 'Invoice #',
|
||||
download: 'PDF',
|
||||
cancelAtEnd: 'Cancels at period end',
|
||||
},
|
||||
fr: {
|
||||
title: 'Facturation',
|
||||
eyebrow: 'Plateforme',
|
||||
mrr: 'MRR (MAD)',
|
||||
active: 'Actifs',
|
||||
trialing: 'Essai',
|
||||
pastDue: 'En retard',
|
||||
cancelled: 'Annulés',
|
||||
company: 'Entreprise',
|
||||
plan: 'Plan',
|
||||
period: 'Période',
|
||||
status: 'Statut',
|
||||
currency: 'Devise',
|
||||
nextRenewal: 'Prochain renouvellement',
|
||||
invoices: 'Factures',
|
||||
actions: 'Actions',
|
||||
viewInvoices: 'Factures',
|
||||
loading: 'Chargement…',
|
||||
empty: 'Aucun abonnement trouvé',
|
||||
filterAll: 'Tous',
|
||||
filterActive: 'Actifs',
|
||||
filterTrialing: 'Essai',
|
||||
filterPastDue: 'En retard',
|
||||
filterCancelled: 'Annulés',
|
||||
closeModal: 'Fermer',
|
||||
invoicesFor: 'Factures pour',
|
||||
noInvoices: 'Aucune facture trouvée',
|
||||
amount: 'Montant',
|
||||
invoiceStatus: 'Statut',
|
||||
provider: 'Fournisseur',
|
||||
paidAt: 'Payé le',
|
||||
date: 'Date',
|
||||
invoiceNo: 'N° Facture',
|
||||
download: 'PDF',
|
||||
cancelAtEnd: 'Annulé en fin de période',
|
||||
},
|
||||
ar: {
|
||||
title: 'الفوترة',
|
||||
eyebrow: 'المنصة',
|
||||
mrr: 'الإيراد الشهري (MAD)',
|
||||
active: 'نشط',
|
||||
trialing: 'تجريبي',
|
||||
pastDue: 'متأخر',
|
||||
cancelled: 'ملغى',
|
||||
company: 'الشركة',
|
||||
plan: 'الخطة',
|
||||
period: 'الفترة',
|
||||
status: 'الحالة',
|
||||
currency: 'العملة',
|
||||
nextRenewal: 'التجديد القادم',
|
||||
invoices: 'الفواتير',
|
||||
actions: 'الإجراءات',
|
||||
viewInvoices: 'الفواتير',
|
||||
loading: 'جارٍ التحميل…',
|
||||
empty: 'لا توجد اشتراكات',
|
||||
filterAll: 'الكل',
|
||||
filterActive: 'نشط',
|
||||
filterTrialing: 'تجريبي',
|
||||
filterPastDue: 'متأخر',
|
||||
filterCancelled: 'ملغى',
|
||||
closeModal: 'إغلاق',
|
||||
invoicesFor: 'فواتير',
|
||||
noInvoices: 'لا توجد فواتير',
|
||||
amount: 'المبلغ',
|
||||
invoiceStatus: 'الحالة',
|
||||
provider: 'المزود',
|
||||
paidAt: 'تاريخ الدفع',
|
||||
date: 'التاريخ',
|
||||
invoiceNo: 'رقم الفاتورة',
|
||||
download: 'PDF',
|
||||
cancelAtEnd: 'يُلغى في نهاية الفترة',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>([])
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
|
||||
const [invoiceModal, setInvoiceModal] = useState<{ companyId: string; companyName: string } | null>(null)
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [invoicesLoading, setInvoicesLoading] = useState(false)
|
||||
const [invoicesError, setInvoicesError] = useState<string | null>(null)
|
||||
|
||||
async function fetchBilling(status?: string) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const params = new URLSearchParams({ pageSize: '50' })
|
||||
if (status) params.set('status', status)
|
||||
const res = await fetch(`${API_BASE}/admin/billing?${params}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
cache: 'no-store',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
|
||||
setSubscriptions(json.data ?? [])
|
||||
setStats(json.stats ?? null)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchBilling(statusFilter || undefined) }, [statusFilter])
|
||||
|
||||
async function downloadInvoicePdf(invoiceId: string, createdAt: string) {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/billing/invoices/${invoiceId}/pdf`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to generate PDF')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${buildInvoiceNumber(invoiceId, createdAt)}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
// silent — no toast system available here
|
||||
}
|
||||
}
|
||||
|
||||
async function openInvoices(companyId: string, companyName: string) {
|
||||
setInvoiceModal({ companyId, companyName })
|
||||
setInvoices([])
|
||||
setInvoicesError(null)
|
||||
setInvoicesLoading(true)
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/billing/${companyId}/invoices?pageSize=50`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? `Error ${res.status}`)
|
||||
setInvoices(json.data ?? [])
|
||||
} catch (err: any) {
|
||||
setInvoicesError(err.message)
|
||||
} finally {
|
||||
setInvoicesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: copy.filterAll },
|
||||
{ key: 'ACTIVE', label: copy.filterActive },
|
||||
{ key: 'TRIALING', label: copy.filterTrialing },
|
||||
{ key: 'PAST_DUE', label: copy.filterPastDue },
|
||||
{ key: 'CANCELLED', label: copy.filterCancelled },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{copy.eyebrow}</p>
|
||||
<h1 className="mt-1 text-3xl font-black">{copy.title}</h1>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
|
||||
<div className="col-span-2 sm:col-span-1 panel p-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.mrr}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-emerald-400">{(stats.mrr / 100).toFixed(0)}</p>
|
||||
</div>
|
||||
<div className="panel p-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.active}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-zinc-100">{stats.activeCount}</p>
|
||||
</div>
|
||||
<div className="panel p-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.trialing}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-sky-400">{stats.trialingCount}</p>
|
||||
</div>
|
||||
<div className="panel p-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.pastDue}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-amber-400">{stats.pastDueCount}</p>
|
||||
</div>
|
||||
<div className="panel p-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.cancelled}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-zinc-400">{stats.cancelledCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => setStatusFilter(f.key)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
statusFilter === f.key
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.company}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.plan}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.period}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.status}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.currency}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.nextRenewal}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.invoices}</th>
|
||||
<th className="px-6 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={8} className="px-6 py-12 text-center text-zinc-500">{copy.loading}</td></tr>
|
||||
) : subscriptions.length === 0 ? (
|
||||
<tr><td colSpan={8} className="px-6 py-12 text-center text-zinc-500">{copy.empty}</td></tr>
|
||||
) : subscriptions.map((sub) => (
|
||||
<tr key={sub.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium text-zinc-100">{sub.company.name}</p>
|
||||
<p className="text-xs text-zinc-500">{sub.company.email}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`font-semibold ${PLAN_COLORS[sub.plan] ?? 'text-zinc-300'}`}>{sub.plan}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-400 text-xs">{sub.billingPeriod}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium w-fit ${SUB_STATUS_COLORS[sub.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{sub.status}
|
||||
</span>
|
||||
{sub.cancelAtPeriodEnd && (
|
||||
<span className="text-xs text-amber-500">{copy.cancelAtEnd}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-400 text-xs">{sub.currency}</td>
|
||||
<td className="px-6 py-4 text-zinc-400 text-xs">{fmtDate(sub.currentPeriodEnd)}</td>
|
||||
<td className="px-6 py-4 text-zinc-400 text-xs">{sub._count.invoices}</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => openInvoices(sub.company.id, sub.company.name)}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-xs font-medium text-zinc-200 transition-colors"
|
||||
>
|
||||
{copy.viewInvoices}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{invoiceModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-zinc-900 border border-zinc-800 shadow-2xl flex flex-col max-h-[80vh]">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.invoicesFor}</p>
|
||||
<p className="font-semibold text-zinc-100">{invoiceModal.companyName}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setInvoiceModal(null); setInvoicesError(null) }}
|
||||
className="p-2 rounded-lg text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{invoicesLoading ? (
|
||||
<div className="px-6 py-12 text-center text-zinc-500">{copy.loading}</div>
|
||||
) : invoicesError ? (
|
||||
<div className="px-6 py-12 text-center text-red-400 text-sm">{invoicesError}</div>
|
||||
) : invoices.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center text-zinc-500">{copy.noInvoices}</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-zinc-900">
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.invoiceNo}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.date}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.amount}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.invoiceStatus}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.provider}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.paidAt}</th>
|
||||
<th className="px-6 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{invoices.map((inv) => (
|
||||
<tr key={inv.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-3 text-zinc-300 text-xs font-mono">{buildInvoiceNumber(inv.id, inv.createdAt)}</td>
|
||||
<td className="px-6 py-3 text-zinc-400 text-xs">{fmtDate(inv.createdAt)}</td>
|
||||
<td className="px-6 py-3 font-medium text-zinc-100">{fmt(inv.amount, inv.currency)}</td>
|
||||
<td className="px-6 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS_COLORS[inv.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{inv.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-zinc-500 text-xs">{inv.paymentProvider}</td>
|
||||
<td className="px-6 py-3 text-zinc-400 text-xs">{fmtDate(inv.paidAt)}</td>
|
||||
<td className="px-6 py-3">
|
||||
<button
|
||||
onClick={() => downloadInvoicePdf(inv.id, inv.createdAt)}
|
||||
title="Download PDF"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-xs font-medium text-zinc-300 transition-colors"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
{copy.download}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-zinc-800 flex justify-end">
|
||||
<button
|
||||
onClick={() => { setInvoiceModal(null); setInvoicesError(null) }}
|
||||
className="px-4 py-2 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-sm font-medium text-zinc-200 transition-colors"
|
||||
>
|
||||
{copy.closeModal}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user