initial commit
This commit is contained in:
188
components/admin/AdminLayout.tsx
Normal file
188
components/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
CreditCard,
|
||||
Settings,
|
||||
FileText,
|
||||
Menu,
|
||||
X,
|
||||
LogOut,
|
||||
BarChart3,
|
||||
Download,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/admin',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
name: 'Users',
|
||||
href: '/admin/users',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
name: 'Billing',
|
||||
href: '/admin/billing',
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: 'Services',
|
||||
href: '/admin/services',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
name: 'Analytics',
|
||||
href: '/admin/analytics',
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
name: 'Reports',
|
||||
href: '/admin/reports',
|
||||
icon: Download,
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
href: '/admin/settings',
|
||||
icon: Settings,
|
||||
},
|
||||
]
|
||||
|
||||
export default function AdminLayout({ children }: AdminLayoutProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
|
||||
const handleLogout = () => {
|
||||
// Clear auth tokens and redirect
|
||||
document.cookie = 'token=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT'
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/auth'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-blue-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||
{/* Mobile sidebar overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black bg-opacity-50 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-50 w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 shadow-xl transform transition-transform duration-300 ease-in-out lg:translate-x-0',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center justify-between px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<Link href="/admin" className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-r from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center shadow-lg">
|
||||
<span className="text-white font-bold text-sm">SP</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-gray-900 dark:text-white">Admin</span>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-6 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href || (item.href !== '/admin' && pathname.startsWith(item.href))
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 group',
|
||||
isActive
|
||||
? 'bg-blue-600 dark:bg-blue-500 text-white shadow-md'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
)}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn(
|
||||
'mr-3 h-5 w-5 transition-colors',
|
||||
isActive
|
||||
? 'text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-300'
|
||||
)}
|
||||
/>
|
||||
{item.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all duration-200"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="mr-3 h-5 w-5" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:pl-64">
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-30 bg-white/90 dark:bg-gray-900/90 backdrop-blur-md border-b border-gray-200 dark:border-gray-700 px-4 py-3 lg:px-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="lg:hidden text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
SiliconPin Admin Panel
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="p-4 lg:p-6">
|
||||
<div className="max-w-7xl mx-auto">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
567
components/admin/BillingManagement.tsx
Normal file
567
components/admin/BillingManagement.tsx
Normal file
@@ -0,0 +1,567 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
130
components/admin/DashboardStats.tsx
Normal file
130
components/admin/DashboardStats.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Users, DollarSign, CreditCard, TrendingUp, UserCheck, Activity } from 'lucide-react'
|
||||
|
||||
interface DashboardStatsProps {
|
||||
stats: {
|
||||
users: {
|
||||
total: number
|
||||
active: number
|
||||
newThisMonth: number
|
||||
verified: number
|
||||
verificationRate: string
|
||||
}
|
||||
revenue: {
|
||||
total: number
|
||||
monthly: number
|
||||
weekly: number
|
||||
}
|
||||
transactions: {
|
||||
total: number
|
||||
monthly: number
|
||||
}
|
||||
services: {
|
||||
total: number
|
||||
active: number
|
||||
}
|
||||
developerRequests: {
|
||||
total: number
|
||||
pending: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function DashboardStats({ stats }: DashboardStatsProps) {
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: 'Total Users',
|
||||
value: stats.users.total.toLocaleString(),
|
||||
change: `+${stats.users.newThisMonth} this month`,
|
||||
icon: Users,
|
||||
color: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
title: 'Total Revenue',
|
||||
value: formatCurrency(stats.revenue.total),
|
||||
change: `${formatCurrency(stats.revenue.monthly)} this month`,
|
||||
icon: DollarSign,
|
||||
color: 'text-green-600',
|
||||
},
|
||||
{
|
||||
title: 'Active Services',
|
||||
value: stats.services.active.toLocaleString(),
|
||||
change: `${stats.services.total} total services`,
|
||||
icon: Activity,
|
||||
color: 'text-purple-600',
|
||||
},
|
||||
{
|
||||
title: 'Verified Users',
|
||||
value: `${stats.users.verificationRate}%`,
|
||||
change: `${stats.users.verified} verified`,
|
||||
icon: UserCheck,
|
||||
color: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
title: 'Transactions',
|
||||
value: stats.transactions.total.toLocaleString(),
|
||||
change: `+${stats.transactions.monthly} this month`,
|
||||
icon: CreditCard,
|
||||
color: 'text-orange-600',
|
||||
},
|
||||
{
|
||||
title: 'Developer Requests',
|
||||
value: stats.developerRequests.total.toLocaleString(),
|
||||
change: `${stats.developerRequests.pending} pending`,
|
||||
icon: TrendingUp,
|
||||
color: 'text-indigo-600',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{statCards.map((card, index) => {
|
||||
const gradients = [
|
||||
'from-blue-500 to-blue-600',
|
||||
'from-green-500 to-emerald-600',
|
||||
'from-purple-500 to-indigo-600',
|
||||
'from-emerald-500 to-teal-600',
|
||||
'from-orange-500 to-red-500',
|
||||
'from-indigo-500 to-purple-600',
|
||||
]
|
||||
const bgGradient = gradients[index % gradients.length]
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
className="border border-gray-200 dark:border-gray-700 shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
{card.title}
|
||||
</CardTitle>
|
||||
<div className={`p-2 rounded-lg bg-gradient-to-r ${bgGradient} shadow-lg`}>
|
||||
<card.icon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
{card.value}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
||||
{card.change}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
137
components/admin/RecentActivity.tsx
Normal file
137
components/admin/RecentActivity.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface RecentActivityProps {
|
||||
recentUsers: Array<{
|
||||
_id: string
|
||||
name: string
|
||||
email: string
|
||||
siliconId: string
|
||||
createdAt: string
|
||||
}>
|
||||
recentTransactions: Array<{
|
||||
_id: string
|
||||
type: string
|
||||
amount: number
|
||||
description: string
|
||||
status: string
|
||||
createdAt: string
|
||||
userId: {
|
||||
name: string
|
||||
email: string
|
||||
siliconId: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
export default function RecentActivity({ recentUsers, recentTransactions }: RecentActivityProps) {
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const getTransactionBadgeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'credit':
|
||||
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300'
|
||||
case 'debit':
|
||||
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300'
|
||||
default:
|
||||
return 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Users */}
|
||||
<Card className="border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Recent Users
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recentUsers.length === 0 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">No recent users</p>
|
||||
) : (
|
||||
recentUsers.map((user) => (
|
||||
<div
|
||||
key={user._id}
|
||||
className="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-b-0"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{user.name}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{user.email}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500">ID: {user.siliconId}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{user.createdAt
|
||||
? formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })
|
||||
: 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Transactions */}
|
||||
<Card className="border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Recent Transactions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recentTransactions.length === 0 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">No recent transactions</p>
|
||||
) : (
|
||||
recentTransactions.map((transaction) => (
|
||||
<div
|
||||
key={transaction._id}
|
||||
className="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-b-0"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className={getTransactionBadgeColor(transaction.type)}>
|
||||
{transaction.type}
|
||||
</Badge>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{formatCurrency(transaction.amount)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{transaction.description}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||
{transaction.userId?.name} ({transaction.userId?.siliconId})
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{transaction.createdAt
|
||||
? formatDistanceToNow(new Date(transaction.createdAt), { addSuffix: true })
|
||||
: 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
284
components/admin/ReportsManagement.tsx
Normal file
284
components/admin/ReportsManagement.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Download, FileText, Users, CreditCard, Activity, BarChart3 } from 'lucide-react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
|
||||
const reportTypes = [
|
||||
{
|
||||
id: 'users',
|
||||
name: 'Users Report',
|
||||
description: 'Export all user data including registration info, roles, and verification status',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
id: 'transactions',
|
||||
name: 'Transactions Report',
|
||||
description: 'Export all transaction data including payments, refunds, and balance changes',
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
id: 'billing',
|
||||
name: 'Billing Report',
|
||||
description: 'Export billing records including service purchases and payment status',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
id: 'developer-requests',
|
||||
name: 'Developer Requests Report',
|
||||
description: 'Export developer hire requests and their status',
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
name: 'Summary Report',
|
||||
description: 'Comprehensive overview with key metrics and statistics',
|
||||
icon: BarChart3,
|
||||
},
|
||||
]
|
||||
|
||||
export default function ReportsManagement() {
|
||||
const [selectedReport, setSelectedReport] = useState('')
|
||||
const [format, setFormat] = useState('json')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
if (!selectedReport) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please select a report type',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setGenerating(true)
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('token') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('token='))
|
||||
?.split('=')[1]
|
||||
|
||||
const params = new URLSearchParams({
|
||||
type: selectedReport,
|
||||
format,
|
||||
...(dateFrom && { dateFrom }),
|
||||
...(dateTo && { dateTo }),
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/admin/reports?${params}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate report')
|
||||
}
|
||||
|
||||
if (format === 'csv') {
|
||||
// Handle CSV download
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${selectedReport}_report_${new Date().toISOString().split('T')[0]}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} else {
|
||||
// Handle JSON download
|
||||
const data = await response.json()
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${selectedReport}_report_${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Report generated and downloaded successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Report generation error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to generate report',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickExport = async (reportType: string) => {
|
||||
setSelectedReport(reportType)
|
||||
setFormat('csv')
|
||||
|
||||
// Trigger immediate export
|
||||
setTimeout(() => {
|
||||
handleGenerateReport()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Quick Export Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{reportTypes.map((report) => (
|
||||
<Card key={report.id} className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-2">
|
||||
<report.icon className="h-5 w-5 text-blue-600 mr-2" />
|
||||
<h3 className="font-semibold">{report.name}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">{report.description}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleQuickExport(report.id)}
|
||||
disabled={generating}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Quick Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom Report Generator */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Custom Report Generator</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="reportType">Report Type</Label>
|
||||
<Select value={selectedReport} onValueChange={setSelectedReport}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select report type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{reportTypes.map((report) => (
|
||||
<SelectItem key={report.id} value={report.id}>
|
||||
{report.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="format">Format</Label>
|
||||
<Select value={format} onValueChange={setFormat}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="csv">CSV</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="dateFrom">From Date</Label>
|
||||
<Input
|
||||
id="dateFrom"
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="dateTo">To Date</Label>
|
||||
<Input
|
||||
id="dateTo"
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleGenerateReport} disabled={generating || !selectedReport}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{generating ? 'Generating...' : 'Generate Report'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Report Guidelines */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Report Guidelines</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">Data Privacy</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Exported reports contain sensitive user data. Ensure compliance with data protection
|
||||
regulations and handle all exported data securely.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">File Formats</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>
|
||||
• <strong>JSON:</strong> Complete data with full structure, suitable for technical
|
||||
analysis
|
||||
</li>
|
||||
<li>
|
||||
• <strong>CSV:</strong> Simplified tabular format, suitable for spreadsheet
|
||||
applications
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">Date Filtering</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Use date filters to limit report scope. Large date ranges may result in slower
|
||||
generation times and larger file sizes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
665
components/admin/ServiceManagement.tsx
Normal file
665
components/admin/ServiceManagement.tsx
Normal file
@@ -0,0 +1,665 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Edit, X, ChevronLeft, ChevronRight, Server, Users, Activity } from 'lucide-react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface BillingService {
|
||||
_id: string
|
||||
user_email: string
|
||||
silicon_id: string
|
||||
service_type: string
|
||||
service_name: string
|
||||
amount: number
|
||||
service_status: 'active' | 'inactive' | 'suspended' | 'cancelled'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface DeveloperRequest {
|
||||
_id: string
|
||||
userId: {
|
||||
name: string
|
||||
email: string
|
||||
siliconId: string
|
||||
}
|
||||
planType: string
|
||||
projectDescription: string
|
||||
status: 'pending' | 'approved' | 'in_progress' | 'completed' | 'cancelled'
|
||||
assignedDeveloper?:
|
||||
| {
|
||||
name: string
|
||||
email: string
|
||||
skills: string[]
|
||||
}
|
||||
| string
|
||||
estimatedCompletionDate?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface ServiceStats {
|
||||
_id: string
|
||||
count: number
|
||||
revenue: number
|
||||
activeServices: number
|
||||
}
|
||||
|
||||
export default function ServiceManagement() {
|
||||
const [billingServices, setBillingServices] = useState<BillingService[]>([])
|
||||
const [developerRequests, setDeveloperRequests] = useState<DeveloperRequest[]>([])
|
||||
const [serviceStats, setServiceStats] = useState<ServiceStats[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [serviceTypeFilter, setServiceTypeFilter] = useState('all')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [editingService, setEditingService] = useState<any>(null)
|
||||
const [editForm, setEditForm] = useState({
|
||||
status: '',
|
||||
assignedDeveloper: '',
|
||||
estimatedCompletionDate: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const fetchServices = 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',
|
||||
serviceType: serviceTypeFilter,
|
||||
status: statusFilter,
|
||||
...(dateFrom && { dateFrom }),
|
||||
...(dateTo && { dateTo }),
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/admin/services?${params}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch services data')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setBillingServices(data.billingServices)
|
||||
setDeveloperRequests(data.developerRequests)
|
||||
setServiceStats(data.serviceStats)
|
||||
setTotalPages(data.pagination.totalPages)
|
||||
} catch (error) {
|
||||
console.error('Services fetch error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load services data',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [serviceTypeFilter, statusFilter, dateFrom, dateTo, currentPage])
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices()
|
||||
}, [fetchServices])
|
||||
|
||||
const handleEditService = (service: any, type: 'billing' | 'developer') => {
|
||||
setEditingService({ ...service, type })
|
||||
if (type === 'billing') {
|
||||
setEditForm({
|
||||
status: service.service_status,
|
||||
assignedDeveloper: '',
|
||||
estimatedCompletionDate: '',
|
||||
notes: '',
|
||||
})
|
||||
} else {
|
||||
setEditForm({
|
||||
status: service.status,
|
||||
assignedDeveloper:
|
||||
typeof service.assignedDeveloper === 'object' && service.assignedDeveloper?.name
|
||||
? service.assignedDeveloper.name
|
||||
: typeof service.assignedDeveloper === 'string'
|
||||
? service.assignedDeveloper
|
||||
: '',
|
||||
estimatedCompletionDate: service.estimatedCompletionDate || '',
|
||||
notes: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateService = async () => {
|
||||
if (!editingService) return
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('token') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('token='))
|
||||
?.split('=')[1]
|
||||
|
||||
const action = editingService.type === 'billing' ? 'updateBilling' : 'updateDeveloperRequest'
|
||||
|
||||
const response = await fetch('/api/admin/services', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
serviceId: editingService._id,
|
||||
serviceType: editingService.type,
|
||||
data:
|
||||
editingService.type === 'billing'
|
||||
? { service_status: editForm.status }
|
||||
: {
|
||||
status: editForm.status,
|
||||
assignedDeveloper: editForm.assignedDeveloper,
|
||||
estimatedCompletionDate: editForm.estimatedCompletionDate,
|
||||
notes: editForm.notes,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update service')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (editingService.type === 'billing') {
|
||||
setBillingServices(
|
||||
billingServices.map((s) => (s._id === editingService._id ? data.service : s))
|
||||
)
|
||||
} else {
|
||||
setDeveloperRequests(
|
||||
developerRequests.map((s) => (s._id === editingService._id ? data.service : s))
|
||||
)
|
||||
}
|
||||
|
||||
setEditingService(null)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Service updated successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Service update error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update service',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelService = async (serviceId: string, type: 'billing' | 'developer') => {
|
||||
if (!confirm('Are you sure you want to cancel this service?')) return
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('token') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('token='))
|
||||
?.split('=')[1]
|
||||
|
||||
const response = await fetch('/api/admin/services', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'cancelService',
|
||||
serviceId,
|
||||
serviceType: type,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to cancel service')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (type === 'billing') {
|
||||
setBillingServices(billingServices.map((s) => (s._id === serviceId ? data.service : s)))
|
||||
} else {
|
||||
setDeveloperRequests(developerRequests.map((s) => (s._id === serviceId ? data.service : s)))
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Service cancelled successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Service cancel error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to cancel service',
|
||||
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 'active':
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'pending':
|
||||
case 'approved':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
case 'in_progress':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case 'cancelled':
|
||||
case 'inactive':
|
||||
return 'bg-red-100 text-red-800'
|
||||
case 'suspended':
|
||||
return 'bg-orange-100 text-orange-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Service Statistics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{serviceStats.map((stat) => (
|
||||
<Card key={stat._id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 capitalize">{stat._id.replace('_', ' ')}</p>
|
||||
<p className="text-2xl font-bold">{stat.count}</p>
|
||||
<p className="text-sm text-green-600">
|
||||
{stat.activeServices} active • {formatCurrency(stat.revenue)}
|
||||
</p>
|
||||
</div>
|
||||
<Server className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Service Management</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<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={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</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 Services */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<Activity className="h-5 w-5 mr-2" />
|
||||
Billing Services
|
||||
</h3>
|
||||
<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">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>
|
||||
{billingServices.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-8 text-gray-500">
|
||||
No billing services found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
billingServices.map((service) => (
|
||||
<tr key={service._id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<p className="font-medium">{service.user_email}</p>
|
||||
<p className="text-sm text-gray-500">ID: {service.silicon_id}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<p className="font-medium">{service.service_name}</p>
|
||||
<p className="text-sm text-gray-500 capitalize">
|
||||
{service.service_type.replace('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="font-medium">{formatCurrency(service.amount)}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge className={getStatusBadgeColor(service.service_status)}>
|
||||
{service.service_status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
{service.created_at
|
||||
? formatDistanceToNow(new Date(service.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={() => handleEditService(service, 'billing')}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCancelService(service._id, 'billing')}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Developer Requests */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<Users className="h-5 w-5 mr-2" />
|
||||
Developer Requests
|
||||
</h3>
|
||||
<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">Plan</th>
|
||||
<th className="text-left py-3 px-4">Description</th>
|
||||
<th className="text-left py-3 px-4">Status</th>
|
||||
<th className="text-left py-3 px-4">Developer</th>
|
||||
<th className="text-left py-3 px-4">Date</th>
|
||||
<th className="text-left py-3 px-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{developerRequests.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-8 text-gray-500">
|
||||
No developer requests found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
developerRequests.map((request) => (
|
||||
<tr key={request._id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<p className="font-medium">{request.userId.name}</p>
|
||||
<p className="text-sm text-gray-500">{request.userId.email}</p>
|
||||
<p className="text-xs text-gray-400">ID: {request.userId.siliconId}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="outline">{request.planType}</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<p className="text-sm max-w-xs truncate">{request.projectDescription}</p>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge className={getStatusBadgeColor(request.status)}>
|
||||
{request.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-sm">
|
||||
{typeof request.assignedDeveloper === 'object' &&
|
||||
request.assignedDeveloper?.name
|
||||
? request.assignedDeveloper.name
|
||||
: typeof request.assignedDeveloper === 'string'
|
||||
? request.assignedDeveloper
|
||||
: 'Unassigned'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
{request.createdAt
|
||||
? formatDistanceToNow(new Date(request.createdAt), {
|
||||
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={() => handleEditService(request, 'developer')}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCancelService(request._id, 'developer')}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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 Service Dialog */}
|
||||
<Dialog open={!!editingService} onOpenChange={() => setEditingService(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Edit {editingService?.type === 'billing' ? 'Service' : 'Developer Request'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={editForm.status}
|
||||
onValueChange={(value) => setEditForm({ ...editForm, status: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{editingService?.type === 'billing' ? (
|
||||
<>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
<SelectItem value="suspended">Suspended</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{editingService?.type === 'developer' && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="assignedDeveloper">Assigned Developer</Label>
|
||||
<Input
|
||||
id="assignedDeveloper"
|
||||
value={editForm.assignedDeveloper}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, assignedDeveloper: e.target.value })
|
||||
}
|
||||
placeholder="Developer name or ID"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="estimatedCompletionDate">Estimated Completion Date</Label>
|
||||
<Input
|
||||
id="estimatedCompletionDate"
|
||||
type="date"
|
||||
value={editForm.estimatedCompletionDate}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, estimatedCompletionDate: e.target.value })
|
||||
}
|
||||
/>
|
||||
</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={() => setEditingService(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateService}>Update Service</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
592
components/admin/SystemSettings.tsx
Normal file
592
components/admin/SystemSettings.tsx
Normal file
@@ -0,0 +1,592 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Settings, Save, RefreshCw, Database, Bell, Shield, Users, Activity } from 'lucide-react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface SystemSettingsData {
|
||||
maintenanceMode: boolean
|
||||
registrationEnabled: boolean
|
||||
emailVerificationRequired: boolean
|
||||
maxUserBalance: number
|
||||
defaultUserRole: 'user' | 'admin'
|
||||
systemMessage: string
|
||||
paymentGatewayEnabled: boolean
|
||||
developerHireEnabled: boolean
|
||||
vpsDeploymentEnabled: boolean
|
||||
kubernetesDeploymentEnabled: boolean
|
||||
vpnServiceEnabled: boolean
|
||||
lastUpdated: string
|
||||
updatedBy: string
|
||||
}
|
||||
|
||||
interface SystemStatistics {
|
||||
totalUsers: number
|
||||
adminUsers: number
|
||||
verifiedUsers: number
|
||||
unverifiedUsers: number
|
||||
}
|
||||
|
||||
interface RecentActivity {
|
||||
id: string
|
||||
action: string
|
||||
details: string
|
||||
timestamp: string
|
||||
adminName: string
|
||||
}
|
||||
|
||||
export default function SystemSettings() {
|
||||
const [settings, setSettings] = useState<SystemSettingsData | null>(null)
|
||||
const [statistics, setStatistics] = useState<SystemStatistics | null>(null)
|
||||
const [recentActivities, setRecentActivities] = useState<RecentActivity[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [notificationDialog, setNotificationDialog] = useState(false)
|
||||
const [notificationForm, setNotificationForm] = useState({
|
||||
message: '',
|
||||
targetUsers: 'all',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
}, [])
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('accessToken') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('accessToken='))
|
||||
?.split('=')[1]
|
||||
|
||||
const response = await fetch('/api/admin/settings', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch settings')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setSettings(data.settings)
|
||||
setStatistics(data.statistics)
|
||||
setRecentActivities(data.recentActivities)
|
||||
} catch (error) {
|
||||
console.error('Settings fetch error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load system settings',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
if (!settings) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('accessToken') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('accessToken='))
|
||||
?.split('=')[1]
|
||||
|
||||
const response = await fetch('/api/admin/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'updateSettings',
|
||||
settings,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update settings')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setSettings(data.settings)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: data.message,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Settings update error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update settings',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSystemAction = async (action: string, additionalData?: any) => {
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('accessToken') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('accessToken='))
|
||||
?.split('=')[1]
|
||||
|
||||
const response = await fetch('/api/admin/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
...additionalData,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to ${action}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: data.message,
|
||||
})
|
||||
|
||||
if (action === 'sendSystemNotification') {
|
||||
setNotificationDialog(false)
|
||||
setNotificationForm({ message: '', targetUsers: 'all' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${action} error:`, error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: `Failed to ${action}`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendNotification = () => {
|
||||
handleSystemAction('sendSystemNotification', notificationForm)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!settings || !statistics) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-gray-500">Failed to load system settings</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* System Statistics */}
|
||||
<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 Users</p>
|
||||
<p className="text-2xl font-bold">{statistics.totalUsers}</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Admin Users</p>
|
||||
<p className="text-2xl font-bold">{statistics.adminUsers}</p>
|
||||
</div>
|
||||
<Shield className="h-8 w-8 text-purple-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Verified Users</p>
|
||||
<p className="text-2xl font-bold">{statistics.verifiedUsers}</p>
|
||||
</div>
|
||||
<Activity 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">Unverified Users</p>
|
||||
<p className="text-2xl font-bold">{statistics.unverifiedUsers}</p>
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-orange-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* System Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
System Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* General Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">General</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="maintenanceMode">Maintenance Mode</Label>
|
||||
<p className="text-sm text-gray-500">Disable access for non-admin users</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="maintenanceMode"
|
||||
checked={settings.maintenanceMode}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, maintenanceMode: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="registrationEnabled">Registration Enabled</Label>
|
||||
<p className="text-sm text-gray-500">Allow new user registrations</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="registrationEnabled"
|
||||
checked={settings.registrationEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, registrationEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="emailVerificationRequired">Email Verification Required</Label>
|
||||
<p className="text-sm text-gray-500">Require email verification for new users</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="emailVerificationRequired"
|
||||
checked={settings.emailVerificationRequired}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, emailVerificationRequired: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="maxUserBalance">Max User Balance (₹)</Label>
|
||||
<Input
|
||||
id="maxUserBalance"
|
||||
type="number"
|
||||
value={settings.maxUserBalance}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, maxUserBalance: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="defaultUserRole">Default User Role</Label>
|
||||
<Select
|
||||
value={settings.defaultUserRole}
|
||||
onValueChange={(value: 'user' | 'admin') =>
|
||||
setSettings({ ...settings, defaultUserRole: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="systemMessage">System Message</Label>
|
||||
<Textarea
|
||||
id="systemMessage"
|
||||
value={settings.systemMessage}
|
||||
onChange={(e) => setSettings({ ...settings, systemMessage: e.target.value })}
|
||||
placeholder="System-wide message for users..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Services</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="paymentGatewayEnabled">Payment Gateway</Label>
|
||||
<p className="text-sm text-gray-500">Enable payment processing</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="paymentGatewayEnabled"
|
||||
checked={settings.paymentGatewayEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, paymentGatewayEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="developerHireEnabled">Developer Hire Service</Label>
|
||||
<p className="text-sm text-gray-500">Enable developer hiring</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="developerHireEnabled"
|
||||
checked={settings.developerHireEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, developerHireEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="vpsDeploymentEnabled">VPS Deployment</Label>
|
||||
<p className="text-sm text-gray-500">Enable VPS deployments</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="vpsDeploymentEnabled"
|
||||
checked={settings.vpsDeploymentEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, vpsDeploymentEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="kubernetesDeploymentEnabled">Kubernetes Deployment</Label>
|
||||
<p className="text-sm text-gray-500">Enable Kubernetes deployments</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="kubernetesDeploymentEnabled"
|
||||
checked={settings.kubernetesDeploymentEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, kubernetesDeploymentEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="vpnServiceEnabled">VPN Service</Label>
|
||||
<p className="text-sm text-gray-500">Enable VPN services</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="vpnServiceEnabled"
|
||||
checked={settings.vpnServiceEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, vpnServiceEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveSettings} disabled={saving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{settings.lastUpdated && (
|
||||
<div className="text-sm text-gray-500 border-t pt-4">
|
||||
Last updated{' '}
|
||||
{settings.lastUpdated
|
||||
? formatDistanceToNow(new Date(settings.lastUpdated), { addSuffix: true })
|
||||
: 'N/A'}
|
||||
by {settings.updatedBy}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Actions & Recent Activity */}
|
||||
<div className="space-y-6">
|
||||
{/* System Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleSystemAction('clearCache')}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Clear System Cache
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleSystemAction('backupDatabase')}
|
||||
>
|
||||
<Database className="h-4 w-4 mr-2" />
|
||||
Backup Database
|
||||
</Button>
|
||||
|
||||
<Dialog open={notificationDialog} onOpenChange={setNotificationDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
Send System Notification
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send System Notification</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="message">Message</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
value={notificationForm.message}
|
||||
onChange={(e) =>
|
||||
setNotificationForm({ ...notificationForm, message: e.target.value })
|
||||
}
|
||||
placeholder="Enter notification message..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="targetUsers">Target Users</Label>
|
||||
<Select
|
||||
value={notificationForm.targetUsers}
|
||||
onValueChange={(value) =>
|
||||
setNotificationForm({ ...notificationForm, targetUsers: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Users</SelectItem>
|
||||
<SelectItem value="verified">Verified Users</SelectItem>
|
||||
<SelectItem value="unverified">Unverified Users</SelectItem>
|
||||
<SelectItem value="admins">Admin Users</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => setNotificationDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSendNotification}>Send Notification</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activities */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activities</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recentActivities.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No recent activities</p>
|
||||
) : (
|
||||
recentActivities.map((activity) => (
|
||||
<div key={activity.id} className="border-b last:border-b-0 pb-3 last:pb-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">{activity.action}</p>
|
||||
<p className="text-sm text-gray-600">{activity.details}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
by {activity.adminName} •{' '}
|
||||
{activity.timestamp
|
||||
? formatDistanceToNow(new Date(activity.timestamp), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
586
components/admin/UserManagement.tsx
Normal file
586
components/admin/UserManagement.tsx
Normal file
@@ -0,0 +1,586 @@
|
||||
'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,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Search, Edit, Trash2, UserPlus, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface User {
|
||||
_id: string
|
||||
name: string
|
||||
email: string
|
||||
siliconId: string
|
||||
role: 'user' | 'admin'
|
||||
isVerified: boolean
|
||||
balance: number
|
||||
lastLogin: string
|
||||
createdAt: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
interface UserManagementProps {
|
||||
initialUsers?: User[]
|
||||
}
|
||||
|
||||
export default function UserManagement({ initialUsers = [] }: UserManagementProps) {
|
||||
const [users, setUsers] = useState<User[]>(initialUsers)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [roleFilter, setRoleFilter] = useState('all')
|
||||
const [verifiedFilter, setVerifiedFilter] = useState('all')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'user' as 'user' | 'admin',
|
||||
isVerified: false,
|
||||
balance: 0,
|
||||
})
|
||||
const [addForm, setAddForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user' as 'user' | 'admin',
|
||||
isVerified: false,
|
||||
balance: 0,
|
||||
})
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('accessToken') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('accessToken='))
|
||||
?.split('=')[1]
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage.toString(),
|
||||
limit: '20',
|
||||
search,
|
||||
role: roleFilter,
|
||||
verified: verifiedFilter,
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/admin/users?${params}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch users')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setUsers(data.users)
|
||||
setTotalPages(data.pagination.totalPages)
|
||||
} catch (error) {
|
||||
console.error('Users fetch error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load users',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [search, roleFilter, verifiedFilter, currentPage])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [fetchUsers])
|
||||
|
||||
const handleEditUser = (user: User) => {
|
||||
setEditingUser(user)
|
||||
setEditForm({
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
isVerified: user.isVerified,
|
||||
balance: user.balance,
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateUser = async () => {
|
||||
if (!editingUser) return
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('accessToken') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('accessToken='))
|
||||
?.split('=')[1]
|
||||
|
||||
const response = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'update',
|
||||
userId: editingUser._id,
|
||||
data: editForm,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update user')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setUsers(users.map((u) => (u._id === editingUser._id ? data.user : u)))
|
||||
setEditingUser(null)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'User updated successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('User update error:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update user',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async (userId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this user?')) return
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('accessToken') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('accessToken='))
|
||||
?.split('=')[1]
|
||||
|
||||
const response = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'delete',
|
||||
userId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete user')
|
||||
}
|
||||
|
||||
setUsers(users.filter((u) => u._id !== userId))
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'User deleted successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to delete user',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddUser = async () => {
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('accessToken') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('accessToken='))
|
||||
?.split('=')[1]
|
||||
|
||||
const response = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'create',
|
||||
data: addForm,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create user')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setUsers([data.user, ...users])
|
||||
setShowAddDialog(false)
|
||||
setAddForm({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user',
|
||||
isVerified: false,
|
||||
balance: 0,
|
||||
})
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'User created successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to create user',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>User Management</CardTitle>
|
||||
<Button onClick={() => setShowAddDialog(true)}>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Search users..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<SelectTrigger className="w-full md:w-40">
|
||||
<SelectValue placeholder="Role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Roles</SelectItem>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={verifiedFilter} onValueChange={setVerifiedFilter}>
|
||||
<SelectTrigger className="w-full md:w-40">
|
||||
<SelectValue placeholder="Verified" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Users</SelectItem>
|
||||
<SelectItem value="true">Verified</SelectItem>
|
||||
<SelectItem value="false">Unverified</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4">User</th>
|
||||
<th className="text-left py-3 px-4">Role</th>
|
||||
<th className="text-left py-3 px-4">Status</th>
|
||||
<th className="text-left py-3 px-4">Balance</th>
|
||||
<th className="text-left py-3 px-4">Last Login</th>
|
||||
<th className="text-left py-3 px-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-8">
|
||||
Loading users...
|
||||
</td>
|
||||
</tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-8 text-gray-500">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user._id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<p className="font-medium">{user.name}</p>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
<p className="text-xs text-gray-400">ID: {user.siliconId}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant={user.isVerified ? 'default' : 'destructive'}>
|
||||
{user.isVerified ? 'Verified' : 'Unverified'}
|
||||
</Badge>
|
||||
<Badge variant="outline">{user.provider}</Badge>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="font-medium">{formatCurrency(user.balance)}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
{user.lastLogin
|
||||
? formatDistanceToNow(new Date(user.lastLogin), { addSuffix: true })
|
||||
: 'Never'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleEditUser(user)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUser(user._id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 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>
|
||||
)}
|
||||
|
||||
{/* Add User Dialog */}
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="add-name">Name</Label>
|
||||
<Input
|
||||
id="add-name"
|
||||
value={addForm.name}
|
||||
onChange={(e) => setAddForm({ ...addForm, name: e.target.value })}
|
||||
placeholder="Enter user name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="add-email">Email</Label>
|
||||
<Input
|
||||
id="add-email"
|
||||
type="email"
|
||||
value={addForm.email}
|
||||
onChange={(e) => setAddForm({ ...addForm, email: e.target.value })}
|
||||
placeholder="Enter email address"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="add-password">Password</Label>
|
||||
<Input
|
||||
id="add-password"
|
||||
type="password"
|
||||
value={addForm.password}
|
||||
onChange={(e) => setAddForm({ ...addForm, password: e.target.value })}
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="add-role">Role</Label>
|
||||
<Select
|
||||
value={addForm.role}
|
||||
onValueChange={(value: 'user' | 'admin') =>
|
||||
setAddForm({ ...addForm, role: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="add-balance">Initial Balance (₹)</Label>
|
||||
<Input
|
||||
id="add-balance"
|
||||
type="number"
|
||||
value={addForm.balance}
|
||||
onChange={(e) =>
|
||||
setAddForm({ ...addForm, balance: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="add-verified"
|
||||
checked={addForm.isVerified}
|
||||
onCheckedChange={(checked) => setAddForm({ ...addForm, isVerified: checked })}
|
||||
/>
|
||||
<Label htmlFor="add-verified">Verified</Label>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAddUser}>Create User</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit User Dialog */}
|
||||
<Dialog open={!!editingUser} onOpenChange={() => setEditingUser(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={editForm.email}
|
||||
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Select
|
||||
value={editForm.role}
|
||||
onValueChange={(value: 'user' | 'admin') =>
|
||||
setEditForm({ ...editForm, role: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="balance">Balance (₹)</Label>
|
||||
<Input
|
||||
id="balance"
|
||||
type="number"
|
||||
value={editForm.balance}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, balance: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="verified"
|
||||
checked={editForm.isVerified}
|
||||
onCheckedChange={(checked) => setEditForm({ ...editForm, isVerified: checked })}
|
||||
/>
|
||||
<Label htmlFor="verified">Verified</Label>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => setEditingUser(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateUser}>Update User</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user