first commit
This commit is contained in:
408
app/admin/payouts/page.tsx
Normal file
408
app/admin/payouts/page.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { DollarSign, Clock, CheckCircle, XCircle, Eye, Search, Filter } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface Payout {
|
||||
id: string
|
||||
amount: number
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED' | 'PAID'
|
||||
bankDetails: string
|
||||
adminNotes?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
}
|
||||
|
||||
interface PayoutStats {
|
||||
totalRequests: number
|
||||
pendingAmount: number
|
||||
approvedAmount: number
|
||||
paidAmount: number
|
||||
}
|
||||
|
||||
export default function AdminPayoutsPage() {
|
||||
const [payouts, setPayouts] = useState<Payout[]>([])
|
||||
const [stats, setStats] = useState<PayoutStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filter, setFilter] = useState({
|
||||
status: 'all',
|
||||
search: ''
|
||||
})
|
||||
const [selectedPayout, setSelectedPayout] = useState<Payout | null>(null)
|
||||
const [reviewDialogOpen, setReviewDialogOpen] = useState(false)
|
||||
const [reviewStatus, setReviewStatus] = useState<'APPROVED' | 'REJECTED'>('APPROVED')
|
||||
const [adminNotes, setAdminNotes] = useState('')
|
||||
const [processing, setProcessing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchPayouts()
|
||||
fetchStats()
|
||||
}, [])
|
||||
|
||||
const fetchPayouts = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/payouts')
|
||||
if (!response.ok) throw new Error('Failed to fetch payouts')
|
||||
const data = await response.json()
|
||||
setPayouts(data.payouts || [])
|
||||
} catch (error) {
|
||||
console.error('Error fetching payouts:', error)
|
||||
toast.error('Failed to load payouts')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/payouts/stats')
|
||||
if (!response.ok) throw new Error('Failed to fetch stats')
|
||||
const data = await response.json()
|
||||
setStats(data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReviewPayout = async () => {
|
||||
if (!selectedPayout) return
|
||||
|
||||
setProcessing(true)
|
||||
try {
|
||||
const response = await fetch(`/api/admin/payouts/${selectedPayout.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
status: reviewStatus,
|
||||
adminNotes
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update payout')
|
||||
|
||||
toast.success(`Payout ${reviewStatus.toLowerCase()} successfully`)
|
||||
setReviewDialogOpen(false)
|
||||
setSelectedPayout(null)
|
||||
setAdminNotes('')
|
||||
fetchPayouts()
|
||||
fetchStats()
|
||||
} catch (error) {
|
||||
console.error('Error updating payout:', error)
|
||||
toast.error('Failed to update payout')
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openReviewDialog = (payout: Payout) => {
|
||||
setSelectedPayout(payout)
|
||||
setAdminNotes(payout.adminNotes || '')
|
||||
setReviewDialogOpen(true)
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'PENDING': return 'bg-yellow-100 text-yellow-800'
|
||||
case 'APPROVED': return 'bg-blue-100 text-blue-800'
|
||||
case 'REJECTED': return 'bg-red-100 text-red-800'
|
||||
case 'PAID': return 'bg-green-100 text-green-800'
|
||||
default: return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'PENDING': return <Clock className="h-4 w-4 text-yellow-500" />
|
||||
case 'APPROVED': return <CheckCircle className="h-4 w-4 text-blue-500" />
|
||||
case 'REJECTED': return <XCircle className="h-4 w-4 text-red-500" />
|
||||
case 'PAID': return <CheckCircle className="h-4 w-4 text-green-500" />
|
||||
default: return <Clock className="h-4 w-4 text-gray-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const filteredPayouts = payouts.filter(payout => {
|
||||
const matchesStatus = filter.status === 'all' || payout.status === filter.status
|
||||
const matchesSearch = !filter.search ||
|
||||
payout.user.name.toLowerCase().includes(filter.search.toLowerCase()) ||
|
||||
payout.user.email.toLowerCase().includes(filter.search.toLowerCase())
|
||||
|
||||
return matchesStatus && matchesSearch
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center">
|
||||
<div className="w-16 h-16 border-4 border-gray-200 border-t-blue-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-100">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Payout Management</h1>
|
||||
<p className="text-gray-600 mt-2">Review and manage user payout requests</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="bg-gradient-to-r from-blue-500 to-blue-600 text-white">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-blue-100 text-sm">Total Requests</p>
|
||||
<p className="text-2xl font-bold">{stats.totalRequests}</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-blue-200" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-r from-yellow-500 to-yellow-600 text-white">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-yellow-100 text-sm">Pending Amount</p>
|
||||
<p className="text-2xl font-bold">₹{stats.pendingAmount.toFixed(0)}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-yellow-200" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-r from-purple-500 to-purple-600 text-white">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-purple-100 text-sm">Approved Amount</p>
|
||||
<p className="text-2xl font-bold">₹{stats.approvedAmount.toFixed(0)}</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-purple-200" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-r from-green-500 to-green-600 text-white">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-green-100 text-sm">Paid Amount</p>
|
||||
<p className="text-2xl font-bold">₹{stats.paidAmount.toFixed(0)}</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-200" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<CardTitle>Payout Requests</CardTitle>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<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 users..."
|
||||
value={filter.search}
|
||||
onChange={(e) => setFilter(prev => ({ ...prev, search: e.target.value }))}
|
||||
className="pl-10 w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={filter.status} onValueChange={(value) => setFilter(prev => ({ ...prev, status: value }))}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="PENDING">Pending</SelectItem>
|
||||
<SelectItem value="APPROVED">Approved</SelectItem>
|
||||
<SelectItem value="REJECTED">Rejected</SelectItem>
|
||||
<SelectItem value="PAID">Paid</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{filteredPayouts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<DollarSign className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No payout requests found</h3>
|
||||
<p className="text-gray-500">Payout requests will appear here when users submit them</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredPayouts.map((payout, index) => (
|
||||
<motion.div
|
||||
key={payout.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
{getStatusIcon(payout.status)}
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="font-medium text-gray-900">{payout.user.name}</span>
|
||||
<Badge className={getStatusColor(payout.status)}>
|
||||
{payout.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{payout.user.email}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Requested {new Date(payout.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold text-gray-900">₹{payout.amount.toFixed(2)}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Updated {new Date(payout.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openReviewDialog(payout)}
|
||||
disabled={payout.status === 'PAID'}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
{payout.status === 'PENDING' ? 'Review' : 'View'}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Review Dialog */}
|
||||
<Dialog open={reviewDialogOpen} onOpenChange={setReviewDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review Payout Request</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review and take action on this payout request
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedPayout && (
|
||||
<div className="space-y-6">
|
||||
{/* User Info */}
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 mb-2">User Information</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Name:</span>
|
||||
<p className="font-medium">{selectedPayout.user.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Email:</span>
|
||||
<p className="font-medium">{selectedPayout.user.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Amount:</span>
|
||||
<p className="font-bold text-lg">₹{selectedPayout.amount.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Status:</span>
|
||||
<Badge className={getStatusColor(selectedPayout.status)}>
|
||||
{selectedPayout.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bank Details */}
|
||||
<div>
|
||||
<Label>Bank Details</Label>
|
||||
<div className="p-3 bg-gray-50 rounded border mt-1">
|
||||
<pre className="text-sm whitespace-pre-wrap">{selectedPayout.bankDetails}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Section */}
|
||||
{selectedPayout.status === 'PENDING' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Action</Label>
|
||||
<Select value={reviewStatus} onValueChange={(value: 'APPROVED' | 'REJECTED') => setReviewStatus(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="APPROVED">Approve</SelectItem>
|
||||
<SelectItem value="REJECTED">Reject</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Admin Notes</Label>
|
||||
<Textarea
|
||||
value={adminNotes}
|
||||
onChange={(e) => setAdminNotes(e.target.value)}
|
||||
placeholder="Add notes about this decision..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => setReviewDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleReviewPayout} disabled={processing}>
|
||||
{processing ? 'Processing...' : `${reviewStatus === 'APPROVED' ? 'Approve' : 'Reject'} Payout`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing Notes */}
|
||||
{selectedPayout.adminNotes && selectedPayout.status !== 'PENDING' && (
|
||||
<div>
|
||||
<Label>Admin Notes</Label>
|
||||
<div className="p-3 bg-gray-50 rounded border mt-1">
|
||||
<p className="text-sm">{selectedPayout.adminNotes}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user