568 lines
20 KiB
TypeScript
568 lines
20 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import {
|
|
Search,
|
|
Edit,
|
|
RefreshCw,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Download,
|
|
DollarSign,
|
|
} from 'lucide-react'
|
|
import { toast } from '@/hooks/use-toast'
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
|
|
interface BillingRecord {
|
|
_id: string
|
|
user_email: string
|
|
silicon_id: string
|
|
service_type: string
|
|
service_name: string
|
|
amount: number
|
|
tax_amount: number
|
|
discount_applied: number
|
|
payment_status: 'pending' | 'completed' | 'failed' | 'refunded'
|
|
service_status: 'active' | 'inactive' | 'suspended' | 'cancelled'
|
|
billing_cycle: string
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
interface BillingSummary {
|
|
totalAmount: number
|
|
totalTax: number
|
|
totalDiscount: number
|
|
completedCount: number
|
|
pendingCount: number
|
|
failedCount: number
|
|
}
|
|
|
|
export default function BillingManagement() {
|
|
const [billings, setBillings] = useState<BillingRecord[]>([])
|
|
const [summary, setSummary] = useState<BillingSummary | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [search, setSearch] = useState('')
|
|
const [serviceTypeFilter, setServiceTypeFilter] = useState('all')
|
|
const [paymentStatusFilter, setPaymentStatusFilter] = useState('all')
|
|
const [dateFrom, setDateFrom] = useState('')
|
|
const [dateTo, setDateTo] = useState('')
|
|
const [currentPage, setCurrentPage] = useState(1)
|
|
const [totalPages, setTotalPages] = useState(1)
|
|
const [editingBilling, setEditingBilling] = useState<BillingRecord | null>(null)
|
|
const [editForm, setEditForm] = useState({
|
|
payment_status: 'pending' as 'pending' | 'completed' | 'failed' | 'refunded',
|
|
service_status: 'active' as 'active' | 'inactive' | 'suspended' | 'cancelled',
|
|
amount: 0,
|
|
notes: '',
|
|
})
|
|
|
|
const fetchBillings = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const token =
|
|
localStorage.getItem('token') ||
|
|
document.cookie
|
|
.split('; ')
|
|
.find((row) => row.startsWith('token='))
|
|
?.split('=')[1]
|
|
|
|
const params = new URLSearchParams({
|
|
page: currentPage.toString(),
|
|
limit: '20',
|
|
search,
|
|
serviceType: serviceTypeFilter,
|
|
paymentStatus: paymentStatusFilter,
|
|
...(dateFrom && { dateFrom }),
|
|
...(dateTo && { dateTo }),
|
|
})
|
|
|
|
const response = await fetch(`/api/admin/billing?${params}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch billing data')
|
|
}
|
|
|
|
const data = await response.json()
|
|
setBillings(data.billings)
|
|
setSummary(data.summary)
|
|
setTotalPages(data.pagination.totalPages)
|
|
} catch (error) {
|
|
console.error('Billing fetch error:', error)
|
|
toast({
|
|
title: 'Error',
|
|
description: 'Failed to load billing data',
|
|
variant: 'destructive',
|
|
})
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [search, serviceTypeFilter, paymentStatusFilter, dateFrom, dateTo, currentPage])
|
|
|
|
useEffect(() => {
|
|
fetchBillings()
|
|
}, [fetchBillings])
|
|
|
|
const handleEditBilling = (billing: BillingRecord) => {
|
|
setEditingBilling(billing)
|
|
setEditForm({
|
|
payment_status: billing.payment_status,
|
|
service_status: billing.service_status,
|
|
amount: billing.amount,
|
|
notes: '',
|
|
})
|
|
}
|
|
|
|
const handleUpdateBilling = async () => {
|
|
if (!editingBilling) return
|
|
|
|
try {
|
|
const token =
|
|
localStorage.getItem('token') ||
|
|
document.cookie
|
|
.split('; ')
|
|
.find((row) => row.startsWith('token='))
|
|
?.split('=')[1]
|
|
|
|
const response = await fetch('/api/admin/billing', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({
|
|
action: 'update',
|
|
billingId: editingBilling._id,
|
|
data: editForm,
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to update billing record')
|
|
}
|
|
|
|
const data = await response.json()
|
|
setBillings(billings.map((b) => (b._id === editingBilling._id ? data.billing : b)))
|
|
setEditingBilling(null)
|
|
|
|
toast({
|
|
title: 'Success',
|
|
description: 'Billing record updated successfully',
|
|
})
|
|
} catch (error) {
|
|
console.error('Billing update error:', error)
|
|
toast({
|
|
title: 'Error',
|
|
description: 'Failed to update billing record',
|
|
variant: 'destructive',
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleRefund = async (billingId: string) => {
|
|
if (!confirm('Are you sure you want to process a refund for this billing record?')) return
|
|
|
|
try {
|
|
const token =
|
|
localStorage.getItem('token') ||
|
|
document.cookie
|
|
.split('; ')
|
|
.find((row) => row.startsWith('token='))
|
|
?.split('=')[1]
|
|
|
|
const response = await fetch('/api/admin/billing', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({
|
|
action: 'refund',
|
|
billingId,
|
|
data: { refundReason: 'Admin refund' },
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to process refund')
|
|
}
|
|
|
|
const data = await response.json()
|
|
setBillings(billings.map((b) => (b._id === billingId ? data.billing : b)))
|
|
|
|
toast({
|
|
title: 'Success',
|
|
description: data.message,
|
|
})
|
|
} catch (error) {
|
|
console.error('Refund error:', error)
|
|
toast({
|
|
title: 'Error',
|
|
description: 'Failed to process refund',
|
|
variant: 'destructive',
|
|
})
|
|
}
|
|
}
|
|
|
|
const formatCurrency = (amount: number) => {
|
|
return new Intl.NumberFormat('en-IN', {
|
|
style: 'currency',
|
|
currency: 'INR',
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0,
|
|
}).format(amount)
|
|
}
|
|
|
|
const getStatusBadgeColor = (status: string) => {
|
|
switch (status) {
|
|
case 'completed':
|
|
case 'active':
|
|
return 'bg-green-100 text-green-800'
|
|
case 'pending':
|
|
return 'bg-yellow-100 text-yellow-800'
|
|
case 'failed':
|
|
case 'cancelled':
|
|
return 'bg-red-100 text-red-800'
|
|
case 'refunded':
|
|
return 'bg-purple-100 text-purple-800'
|
|
case 'inactive':
|
|
case 'suspended':
|
|
return 'bg-gray-100 text-gray-800'
|
|
default:
|
|
return 'bg-gray-100 text-gray-800'
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Summary Cards */}
|
|
{summary && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-600">Total Revenue</p>
|
|
<p className="text-2xl font-bold">{formatCurrency(summary.totalAmount)}</p>
|
|
</div>
|
|
<DollarSign className="h-8 w-8 text-green-600" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-600">Completed</p>
|
|
<p className="text-2xl font-bold text-green-600">{summary.completedCount}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-600">Pending</p>
|
|
<p className="text-2xl font-bold text-yellow-600">{summary.pendingCount}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-600">Failed</p>
|
|
<p className="text-2xl font-bold text-red-600">{summary.failedCount}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Billing Management</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{/* Filters */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
|
<Input
|
|
placeholder="Search..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
<Select value={serviceTypeFilter} onValueChange={setServiceTypeFilter}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Service Type" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Services</SelectItem>
|
|
<SelectItem value="vps_deployment">VPS</SelectItem>
|
|
<SelectItem value="kubernetes_deployment">Kubernetes</SelectItem>
|
|
<SelectItem value="developer_hire">Developer Hire</SelectItem>
|
|
<SelectItem value="vpn_service">VPN</SelectItem>
|
|
<SelectItem value="hosting_config">Hosting</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={paymentStatusFilter} onValueChange={setPaymentStatusFilter}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Payment Status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Status</SelectItem>
|
|
<SelectItem value="pending">Pending</SelectItem>
|
|
<SelectItem value="completed">Completed</SelectItem>
|
|
<SelectItem value="failed">Failed</SelectItem>
|
|
<SelectItem value="refunded">Refunded</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Input
|
|
type="date"
|
|
placeholder="From Date"
|
|
value={dateFrom}
|
|
onChange={(e) => setDateFrom(e.target.value)}
|
|
/>
|
|
<Input
|
|
type="date"
|
|
placeholder="To Date"
|
|
value={dateTo}
|
|
onChange={(e) => setDateTo(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Billing Table */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b">
|
|
<th className="text-left py-3 px-4">Customer</th>
|
|
<th className="text-left py-3 px-4">Service</th>
|
|
<th className="text-left py-3 px-4">Amount</th>
|
|
<th className="text-left py-3 px-4">Payment Status</th>
|
|
<th className="text-left py-3 px-4">Service Status</th>
|
|
<th className="text-left py-3 px-4">Date</th>
|
|
<th className="text-left py-3 px-4">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={7} className="text-center py-8">
|
|
Loading billing data...
|
|
</td>
|
|
</tr>
|
|
) : billings.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="text-center py-8 text-gray-500">
|
|
No billing records found
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
billings.map((billing) => (
|
|
<tr key={billing._id} className="border-b hover:bg-gray-50">
|
|
<td className="py-3 px-4">
|
|
<div>
|
|
<p className="font-medium">{billing.user_email}</p>
|
|
<p className="text-sm text-gray-500">ID: {billing.silicon_id}</p>
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<div>
|
|
<p className="font-medium">{billing.service_name}</p>
|
|
<p className="text-sm text-gray-500 capitalize">
|
|
{billing.service_type.replace('_', ' ')}
|
|
</p>
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<div>
|
|
<p className="font-medium">{formatCurrency(billing.amount)}</p>
|
|
{billing.tax_amount > 0 && (
|
|
<p className="text-sm text-gray-500">
|
|
Tax: {formatCurrency(billing.tax_amount)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<Badge className={getStatusBadgeColor(billing.payment_status)}>
|
|
{billing.payment_status}
|
|
</Badge>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<Badge className={getStatusBadgeColor(billing.service_status)}>
|
|
{billing.service_status}
|
|
</Badge>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<span className="text-sm text-gray-600">
|
|
{billing.created_at
|
|
? formatDistanceToNow(new Date(billing.created_at), { addSuffix: true })
|
|
: 'N/A'}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleEditBilling(billing)}
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
{billing.payment_status === 'completed' && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleRefund(billing._id)}
|
|
className="text-purple-600 hover:text-purple-700"
|
|
>
|
|
<RefreshCw className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
<Button variant="outline" size="sm">
|
|
<Download className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between mt-6">
|
|
<p className="text-sm text-gray-600">
|
|
Page {currentPage} of {totalPages}
|
|
</p>
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setCurrentPage(currentPage - 1)}
|
|
disabled={currentPage === 1}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setCurrentPage(currentPage + 1)}
|
|
disabled={currentPage === totalPages}
|
|
>
|
|
Next
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit Billing Dialog */}
|
|
<Dialog open={!!editingBilling} onOpenChange={() => setEditingBilling(null)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Billing Record</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="payment_status">Payment Status</Label>
|
|
<Select
|
|
value={editForm.payment_status}
|
|
onValueChange={(value: 'pending' | 'completed' | 'failed' | 'refunded') =>
|
|
setEditForm({ ...editForm, payment_status: value })
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="pending">Pending</SelectItem>
|
|
<SelectItem value="completed">Completed</SelectItem>
|
|
<SelectItem value="failed">Failed</SelectItem>
|
|
<SelectItem value="refunded">Refunded</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="service_status">Service Status</Label>
|
|
<Select
|
|
value={editForm.service_status}
|
|
onValueChange={(value: 'active' | 'inactive' | 'suspended' | 'cancelled') =>
|
|
setEditForm({ ...editForm, service_status: value })
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="active">Active</SelectItem>
|
|
<SelectItem value="inactive">Inactive</SelectItem>
|
|
<SelectItem value="suspended">Suspended</SelectItem>
|
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="amount">Amount</Label>
|
|
<Input
|
|
id="amount"
|
|
type="number"
|
|
value={editForm.amount}
|
|
onChange={(e) =>
|
|
setEditForm({ ...editForm, amount: parseFloat(e.target.value) || 0 })
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="notes">Notes</Label>
|
|
<Textarea
|
|
id="notes"
|
|
value={editForm.notes}
|
|
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
|
|
placeholder="Add notes about this update..."
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end space-x-2">
|
|
<Button variant="outline" onClick={() => setEditingBilling(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleUpdateBilling}>Update Record</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|