first commit
This commit is contained in:
301
app/admin/categories/page.tsx
Normal file
301
app/admin/categories/page.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
'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 { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Search, Plus, Edit, Trash2, MoreHorizontal, Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { CategoryFormDialog } from '@/components/admin/CategoryFormDialog'
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
image: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
_count: {
|
||||
products: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminCategoriesPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
|
||||
const [bulkActionLoading, setBulkActionLoading] = useState(false)
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (search) params.append('search', search)
|
||||
|
||||
const response = await fetch(`/api/admin/categories?${params}`)
|
||||
const data = await response.json()
|
||||
setCategories(data || [])
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error)
|
||||
toast.error('Failed to load categories')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [search])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories()
|
||||
}, [fetchCategories])
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedCategories(categories.map(c => c.id))
|
||||
} else {
|
||||
setSelectedCategories([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectCategory = (categoryId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedCategories(prev => [...prev, categoryId])
|
||||
} else {
|
||||
setSelectedCategories(prev => prev.filter(id => id !== categoryId))
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkAction = async (action: 'activate' | 'deactivate' | 'delete') => {
|
||||
if (selectedCategories.length === 0) {
|
||||
toast.error('Please select at least one category')
|
||||
return
|
||||
}
|
||||
|
||||
setBulkActionLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/admin/categories/bulk', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
categoryIds: selectedCategories,
|
||||
action
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Bulk action failed')
|
||||
|
||||
toast.success(`Successfully ${action}d ${selectedCategories.length} categories`)
|
||||
setSelectedCategories([])
|
||||
fetchCategories()
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${action} categories`)
|
||||
} finally {
|
||||
setBulkActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteCategory = async (categoryId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this category?')) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/categories/${categoryId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Delete failed')
|
||||
|
||||
toast.success('Category deleted successfully')
|
||||
fetchCategories()
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete category')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Categories</h1>
|
||||
<p className="text-gray-600">Manage your product categories</p>
|
||||
</div>
|
||||
<CategoryFormDialog onSuccess={fetchCategories} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>All Categories</CardTitle>
|
||||
<div className="flex items-center space-x-2">
|
||||
{selectedCategories.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-500">
|
||||
{selectedCategories.length} selected
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('activate')}
|
||||
disabled={bulkActionLoading}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('deactivate')}
|
||||
disabled={bulkActionLoading}
|
||||
>
|
||||
Deactivate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('delete')}
|
||||
disabled={bulkActionLoading}
|
||||
className="text-red-600"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<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 categories..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 w-64"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="text-center py-8">Loading...</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedCategories.length === categories.length && categories.length > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Products</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{categories.map((category) => (
|
||||
<TableRow key={category.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedCategories.includes(category.id)}
|
||||
onCheckedChange={(checked) => handleSelectCategory(category.id, !!checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-3">
|
||||
{category.image ? (
|
||||
<Image
|
||||
src={category.image}
|
||||
alt={category.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||
<span className="text-gray-400 text-xs">No Image</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">{category.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="text-sm text-gray-600 max-w-xs truncate">
|
||||
{category.description || 'No description'}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{category._count.products} products
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={category.isActive ? 'default' : 'secondary'}>
|
||||
{category.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(category.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<CategoryFormDialog
|
||||
category={category}
|
||||
mode="edit"
|
||||
onSuccess={fetchCategories}
|
||||
trigger={
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteCategory(category.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
525
app/admin/commissions/page.tsx
Normal file
525
app/admin/commissions/page.tsx
Normal file
@@ -0,0 +1,525 @@
|
||||
'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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Settings,
|
||||
Search,
|
||||
Filter,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
Edit,
|
||||
Plus
|
||||
} from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface Commission {
|
||||
id: string
|
||||
amount: number
|
||||
level: number
|
||||
type: 'REFERRAL' | 'LEVEL' | 'BONUS'
|
||||
status: 'PENDING' | 'APPROVED' | 'PAID' | 'CANCELLED'
|
||||
createdAt: string
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
fromUser: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
order?: {
|
||||
id: string
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
interface CommissionStats {
|
||||
totalCommissions: number
|
||||
pendingAmount: number
|
||||
approvedAmount: number
|
||||
paidAmount: number
|
||||
thisMonthCommissions: number
|
||||
averageCommission: number
|
||||
}
|
||||
|
||||
interface CommissionSetting {
|
||||
id: string
|
||||
level: number
|
||||
percentage: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
PENDING: { label: 'Pending', color: 'bg-yellow-100 text-yellow-800', icon: Clock },
|
||||
APPROVED: { label: 'Approved', color: 'bg-blue-100 text-blue-800', icon: CheckCircle },
|
||||
PAID: { label: 'Paid', color: 'bg-green-100 text-green-800', icon: CheckCircle },
|
||||
CANCELLED: { label: 'Cancelled', color: 'bg-red-100 text-red-800', icon: XCircle }
|
||||
}
|
||||
|
||||
export default function AdminCommissionsPage() {
|
||||
const [commissions, setCommissions] = useState<Commission[]>([])
|
||||
const [stats, setStats] = useState<CommissionStats | null>(null)
|
||||
const [settings, setSettings] = useState<CommissionSetting[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filter, setFilter] = useState({
|
||||
status: 'all',
|
||||
type: 'all',
|
||||
level: 'all',
|
||||
search: ''
|
||||
})
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false)
|
||||
const [editingSetting, setEditingSetting] = useState<CommissionSetting | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [commissionsRes, statsRes, settingsRes] = await Promise.all([
|
||||
fetch('/api/admin/commissions'),
|
||||
fetch('/api/admin/commissions/stats'),
|
||||
fetch('/api/admin/commissions/settings')
|
||||
])
|
||||
|
||||
// Check if responses are ok and contain valid JSON
|
||||
const commissionsData = commissionsRes.ok ? await commissionsRes.json() : { commissions: [] }
|
||||
const statsData = statsRes.ok ? await statsRes.json() : {
|
||||
totalCommissions: 0,
|
||||
pendingAmount: 0,
|
||||
approvedAmount: 0,
|
||||
paidAmount: 0,
|
||||
thisMonthCommissions: 0,
|
||||
averageCommission: 0
|
||||
}
|
||||
const settingsData = settingsRes.ok ? await settingsRes.json() : { settings: [] }
|
||||
|
||||
setCommissions(commissionsData.commissions || [])
|
||||
setStats(statsData)
|
||||
setSettings(settingsData.settings || [])
|
||||
} catch (error) {
|
||||
console.error('Error fetching commission data:', error)
|
||||
toast.error('Failed to load commission data')
|
||||
// Set fallback data
|
||||
setCommissions([])
|
||||
setStats({
|
||||
totalCommissions: 0,
|
||||
pendingAmount: 0,
|
||||
approvedAmount: 0,
|
||||
paidAmount: 0,
|
||||
thisMonthCommissions: 0,
|
||||
averageCommission: 0
|
||||
})
|
||||
setSettings([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const handleStatusUpdate = async (commissionId: string, newStatus: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/commissions/${commissionId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update commission')
|
||||
|
||||
toast.success('Commission status updated successfully')
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('Error updating commission:', error)
|
||||
toast.error('Failed to update commission status')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSettingUpdate = async (setting: CommissionSetting) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/commissions/settings/${setting.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
level: setting.level,
|
||||
percentage: setting.percentage,
|
||||
isActive: setting.isActive
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update setting')
|
||||
|
||||
toast.success('Commission setting updated successfully')
|
||||
fetchData()
|
||||
setEditingSetting(null)
|
||||
setSettingsDialogOpen(false)
|
||||
} catch (error) {
|
||||
console.error('Error updating setting:', error)
|
||||
toast.error('Failed to update commission setting')
|
||||
}
|
||||
}
|
||||
|
||||
const filteredCommissions = commissions.filter(commission => {
|
||||
const matchesStatus = filter.status === 'all' || commission.status === filter.status
|
||||
const matchesType = filter.type === 'all' || commission.type === filter.type
|
||||
const matchesLevel = filter.level === 'all' || commission.level.toString() === filter.level
|
||||
const matchesSearch = !filter.search ||
|
||||
commission.user.name.toLowerCase().includes(filter.search.toLowerCase()) ||
|
||||
commission.user.email.toLowerCase().includes(filter.search.toLowerCase()) ||
|
||||
commission.fromUser.name.toLowerCase().includes(filter.search.toLowerCase())
|
||||
|
||||
return matchesStatus && matchesType && matchesLevel && 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 p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-6"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-transparent">
|
||||
Commission Management
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">Monitor and manage all commission transactions</p>
|
||||
</div>
|
||||
<Dialog open={settingsDialogOpen} onOpenChange={setSettingsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 shadow-lg">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Commission Settings
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Commission Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{settings.map((setting) => (
|
||||
<div key={setting.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">Level {setting.level}</p>
|
||||
<p className="text-sm text-gray-500">{setting.percentage}% commission</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant={setting.isActive ? 'default' : 'secondary'}>
|
||||
{setting.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingSetting(setting)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"
|
||||
>
|
||||
<Card className="bg-gradient-to-r from-blue-500 to-blue-600 text-white border-0 shadow-lg">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-blue-100 text-sm">Total Commissions</p>
|
||||
<p className="text-2xl font-bold">{stats.totalCommissions}</p>
|
||||
</div>
|
||||
<Users 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 border-0 shadow-lg">
|
||||
<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-green-500 to-green-600 text-white border-0 shadow-lg">
|
||||
<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>
|
||||
|
||||
<Card className="bg-gradient-to-r from-purple-500 to-purple-600 text-white border-0 shadow-lg">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-purple-100 text-sm">Avg Commission</p>
|
||||
<p className="text-2xl font-bold">₹{stats.averageCommission.toFixed(0)}</p>
|
||||
</div>
|
||||
<TrendingUp className="h-8 w-8 text-purple-200" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Main Commission Table */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-xl">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4">
|
||||
<CardTitle className="text-2xl font-bold">All Commissions</CardTitle>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<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 bg-white shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<Select value={filter.status} onValueChange={(value) => setFilter(prev => ({ ...prev, status: value }))}>
|
||||
<SelectTrigger className="w-40 bg-white shadow-sm">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="PENDING">Pending</SelectItem>
|
||||
<SelectItem value="APPROVED">Approved</SelectItem>
|
||||
<SelectItem value="PAID">Paid</SelectItem>
|
||||
<SelectItem value="CANCELLED">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filter.type} onValueChange={(value) => setFilter(prev => ({ ...prev, type: value }))}>
|
||||
<SelectTrigger className="w-40 bg-white shadow-sm">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="REFERRAL">Referral</SelectItem>
|
||||
<SelectItem value="LEVEL">Level</SelectItem>
|
||||
<SelectItem value="BONUS">Bonus</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold">Recipient</TableHead>
|
||||
<TableHead className="font-semibold">From User</TableHead>
|
||||
<TableHead className="font-semibold">Amount</TableHead>
|
||||
<TableHead className="font-semibold">Level</TableHead>
|
||||
<TableHead className="font-semibold">Type</TableHead>
|
||||
<TableHead className="font-semibold">Status</TableHead>
|
||||
<TableHead className="font-semibold">Date</TableHead>
|
||||
<TableHead className="font-semibold">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCommissions.map((commission, index) => (
|
||||
<motion.tr
|
||||
key={commission.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-r from-blue-500 to-purple-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-sm font-medium">
|
||||
{commission.user.name?.charAt(0) || 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{commission.user.name}</p>
|
||||
<p className="text-sm text-gray-500">{commission.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{commission.fromUser.name}</p>
|
||||
<p className="text-sm text-gray-500">{commission.fromUser.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-bold text-lg bg-gradient-to-r from-green-600 to-emerald-600 bg-clip-text text-transparent">
|
||||
₹{commission.amount.toFixed(2)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">Level {commission.level}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{commission.type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={statusConfig[commission.status].color}>
|
||||
{statusConfig[commission.status].label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="text-sm">
|
||||
{new Date(commission.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
{commission.status === 'PENDING' && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleStatusUpdate(commission.id, 'APPROVED')}
|
||||
className="text-blue-600 hover:bg-blue-50"
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleStatusUpdate(commission.id, 'CANCELLED')}
|
||||
className="text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{commission.status === 'APPROVED' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleStatusUpdate(commission.id, 'PAID')}
|
||||
className="text-green-600 hover:bg-green-50"
|
||||
>
|
||||
Mark Paid
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{filteredCommissions.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<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 commissions found</h3>
|
||||
<p className="text-gray-500">Try adjusting your search or filter criteria</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Edit Setting Dialog */}
|
||||
{editingSetting && (
|
||||
<Dialog open={!!editingSetting} onOpenChange={() => setEditingSetting(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Commission Setting</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Level</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editingSetting.level}
|
||||
onChange={(e) => setEditingSetting(prev => prev ? { ...prev, level: parseInt(e.target.value) } : null)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Percentage (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editingSetting.percentage}
|
||||
onChange={(e) => setEditingSetting(prev => prev ? { ...prev, percentage: parseFloat(e.target.value) } : null)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={editingSetting.isActive}
|
||||
onChange={(e) => setEditingSetting(prev => prev ? { ...prev, isActive: e.target.checked } : null)}
|
||||
/>
|
||||
<Label htmlFor="isActive">Active</Label>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => setEditingSetting(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => handleSettingUpdate(editingSetting)}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
646
app/admin/forms/page.tsx
Normal file
646
app/admin/forms/page.tsx
Normal file
@@ -0,0 +1,646 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Mail,
|
||||
Search,
|
||||
Filter,
|
||||
Eye,
|
||||
Calendar,
|
||||
User,
|
||||
Building,
|
||||
Phone,
|
||||
MessageSquare,
|
||||
ExternalLink
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface FormResponse {
|
||||
id: string
|
||||
formId: string
|
||||
data: any // Changed from specific interface to any to handle different form types
|
||||
metadata?: {
|
||||
ipAddress?: string
|
||||
userAgent?: string
|
||||
referrer?: string
|
||||
timestamp?: string
|
||||
}
|
||||
status: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export default function AdminFormsPage() {
|
||||
const [forms, setForms] = useState<FormResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedForm, setSelectedForm] = useState<FormResponse | null>(null)
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
// Filters
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [formTypeFilter, setFormTypeFilter] = useState('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const fetchForms = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams({
|
||||
page: pagination.page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
})
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
params.append('status', statusFilter)
|
||||
}
|
||||
|
||||
if (formTypeFilter !== 'all') {
|
||||
params.append('formId', formTypeFilter)
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/forms?${params}`)
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setForms(result.data)
|
||||
setPagination(result.pagination)
|
||||
} else {
|
||||
toast.error('Failed to fetch form responses')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching forms:', error)
|
||||
toast.error('Failed to fetch form responses')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [pagination.page, pagination.limit, statusFilter, formTypeFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchForms()
|
||||
}, [fetchForms])
|
||||
|
||||
const updateFormStatus = async (formId: string, newStatus: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/forms/${formId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Status updated successfully')
|
||||
fetchForms()
|
||||
if (selectedForm && selectedForm.id === formId) {
|
||||
setSelectedForm({ ...selectedForm, status: newStatus })
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to update status')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating status:', error)
|
||||
toast.error('Failed to update status')
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusColors = {
|
||||
new: 'bg-blue-100 text-blue-800',
|
||||
in_progress: 'bg-yellow-100 text-yellow-800',
|
||||
resolved: 'bg-green-100 text-green-800',
|
||||
closed: 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge className={statusColors[status as keyof typeof statusColors] || 'bg-gray-100 text-gray-800'}>
|
||||
{status.replace('_', ' ').toUpperCase()}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const getFormTypeBadge = (formId: string) => {
|
||||
const typeColors = {
|
||||
contact: 'bg-purple-100 text-purple-800',
|
||||
partnership: 'bg-indigo-100 text-indigo-800',
|
||||
bulk_inquiry: 'bg-orange-100 text-orange-800',
|
||||
support: 'bg-teal-100 text-teal-800'
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={typeColors[formId as keyof typeof typeColors]}>
|
||||
{formId.replace('_', ' ').toUpperCase()}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const filteredForms = forms.filter(form => {
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
const data = form.data
|
||||
|
||||
// Handle different form types
|
||||
if (form.formId === 'partnership_application') {
|
||||
return (
|
||||
data.firstName?.toLowerCase().includes(query) ||
|
||||
data.lastName?.toLowerCase().includes(query) ||
|
||||
data.email?.toLowerCase().includes(query) ||
|
||||
data.businessName?.toLowerCase().includes(query) ||
|
||||
data.partnershipTier?.toLowerCase().includes(query) ||
|
||||
data.motivation?.toLowerCase().includes(query)
|
||||
)
|
||||
} else {
|
||||
// Contact forms and other types
|
||||
return (
|
||||
data.name?.toLowerCase().includes(query) ||
|
||||
data.email?.toLowerCase().includes(query) ||
|
||||
data.message?.toLowerCase().includes(query) ||
|
||||
data.subject?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Form Responses</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage and respond to customer inquiries and form submissions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5" />
|
||||
Filters
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name, email, or message..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="new">New</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={formTypeFilter} onValueChange={setFormTypeFilter}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Form Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="contact">Contact</SelectItem>
|
||||
<SelectItem value="partnership">Partnership</SelectItem>
|
||||
<SelectItem value="bulk_inquiry">Bulk Inquiry</SelectItem>
|
||||
<SelectItem value="support">Support</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Form Responses Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Form Responses ({pagination.total})
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Click on any row to view detailed information and respond to inquiries
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Subject</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredForms.map((form) => {
|
||||
// Handle different form types for display
|
||||
const displayName = form.formId === 'partnership_application'
|
||||
? `${form.data.firstName || ''} ${form.data.lastName || ''}`.trim()
|
||||
: form.data.name || 'Unknown'
|
||||
|
||||
const displayEmail = form.data.email || 'No email'
|
||||
|
||||
const displaySubject = form.formId === 'partnership_application'
|
||||
? `${form.data.partnershipTier || 'Unknown'} Partnership Application`
|
||||
: form.data.subject || form.data.inquiryType || 'No subject'
|
||||
|
||||
return (
|
||||
<TableRow key={form.id} className="cursor-pointer hover:bg-muted/50">
|
||||
<TableCell className="font-medium">{displayName}</TableCell>
|
||||
<TableCell>{displayEmail}</TableCell>
|
||||
<TableCell>{getFormTypeBadge(form.formId)}</TableCell>
|
||||
<TableCell>{getStatusBadge(form.status)}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">
|
||||
{displaySubject}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(form.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedForm(form)
|
||||
setShowDetails(true)
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.pages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {((pagination.page - 1) * pagination.limit) + 1} to{' '}
|
||||
{Math.min(pagination.page * pagination.limit, pagination.total)} of{' '}
|
||||
{pagination.total} entries
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPagination(p => ({ ...p, page: p.page - 1 }))}
|
||||
disabled={pagination.page === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
Page {pagination.page} of {pagination.pages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPagination(p => ({ ...p, page: p.page + 1 }))}
|
||||
disabled={pagination.page === pagination.pages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Form Details Dialog */}
|
||||
<Dialog open={showDetails} onOpenChange={setShowDetails}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
Form Response Details
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
View and manage this form submission
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedForm && (
|
||||
<div className="space-y-6">
|
||||
{/* Header Info */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{getFormTypeBadge(selectedForm.formId)}
|
||||
{getStatusBadge(selectedForm.status)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Submitted on {new Date(selectedForm.createdAt).toLocaleDateString()} at{' '}
|
||||
{new Date(selectedForm.createdAt).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={selectedForm.status}
|
||||
onValueChange={(newStatus) => updateFormStatus(selectedForm.id, newStatus)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new">New</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Contact Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Name:</span>
|
||||
<span>
|
||||
{selectedForm.formId === 'partnership_application'
|
||||
? `${selectedForm.data.firstName || ''} ${selectedForm.data.lastName || ''}`.trim()
|
||||
: selectedForm.data.name || 'Unknown'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Email:</span>
|
||||
<a
|
||||
href={`mailto:${selectedForm.data.email}`}
|
||||
className="text-blue-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
{selectedForm.data.email}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{selectedForm.data.phone && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Phone:</span>
|
||||
<a
|
||||
href={`tel:${selectedForm.data.phone}`}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{selectedForm.data.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show business info for partnership applications */}
|
||||
{selectedForm.formId === 'partnership_application' && selectedForm.data.businessName && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Building className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Business:</span>
|
||||
<span>{selectedForm.data.businessName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show company info for other forms */}
|
||||
{selectedForm.formId !== 'partnership_application' && selectedForm.data.company && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Building className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Company:</span>
|
||||
<span>{selectedForm.data.company}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Message/Details Content */}
|
||||
{selectedForm.formId === 'partnership_application' ? (
|
||||
// Partnership Application Details
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Partnership Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="font-medium">Partnership Tier:</span>
|
||||
<Badge variant="outline" className="ml-2">{selectedForm.data.partnershipTier}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Expected Customers:</span>
|
||||
<span className="ml-2">{selectedForm.data.expectedCustomers || 'Not specified'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="font-medium">Business Type:</span>
|
||||
<span className="ml-2">{selectedForm.data.businessType || 'Not specified'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Experience:</span>
|
||||
<span className="ml-2">{selectedForm.data.experience || 'Not specified'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Address Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div><span className="font-medium">Address:</span> {selectedForm.data.address || 'Not provided'}</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div><span className="font-medium">City:</span> {selectedForm.data.city || 'Not provided'}</div>
|
||||
<div><span className="font-medium">State:</span> {selectedForm.data.state || 'Not provided'}</div>
|
||||
<div><span className="font-medium">Zip Code:</span> {selectedForm.data.zipCode || 'Not provided'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Motivation & Plans</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<span className="font-medium">Motivation:</span>
|
||||
<div className="bg-muted/50 p-3 rounded-lg mt-2">
|
||||
<p className="whitespace-pre-wrap">{selectedForm.data.motivation || 'Not provided'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Marketing Plan:</span>
|
||||
<div className="bg-muted/50 p-3 rounded-lg mt-2">
|
||||
<p className="whitespace-pre-wrap">{selectedForm.data.marketingPlan || 'Not provided'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
// Regular Contact Form Message
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Message</CardTitle>
|
||||
{selectedForm.data.subject && (
|
||||
<CardDescription className="font-medium">
|
||||
Subject: {selectedForm.data.subject}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-muted/50 p-4 rounded-lg">
|
||||
<p className="whitespace-pre-wrap">{selectedForm.data.message}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Additional Information */}
|
||||
{(selectedForm.data.inquiryType || selectedForm.data.formSource) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Additional Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{selectedForm.data.inquiryType && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">Inquiry Type:</span>
|
||||
<Badge variant="outline">{selectedForm.data.inquiryType}</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedForm.data.formSource && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">Form Source:</span>
|
||||
<span>{selectedForm.data.formSource}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{selectedForm.metadata && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Technical Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{selectedForm.metadata.ipAddress && (
|
||||
<div>
|
||||
<span className="font-medium">IP Address:</span> {selectedForm.metadata.ipAddress}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedForm.metadata.referrer && (
|
||||
<div>
|
||||
<span className="font-medium">Referrer:</span> {selectedForm.metadata.referrer}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedForm.metadata.userAgent && (
|
||||
<div>
|
||||
<span className="font-medium">User Agent:</span>
|
||||
<div className="mt-1 p-2 bg-muted/50 rounded text-xs break-all">
|
||||
{selectedForm.metadata.userAgent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4 border-t">
|
||||
<Button
|
||||
onClick={() => window.open(`mailto:${selectedForm.data.email}?subject=Re: ${selectedForm.data.subject || 'Your inquiry'}`)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
Reply via Email
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => updateFormStatus(selectedForm.id, 'resolved')}
|
||||
disabled={selectedForm.status === 'resolved'}
|
||||
>
|
||||
Mark as Resolved
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
app/admin/layout.tsx
Normal file
28
app/admin/layout.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { auth } from '@/auth'
|
||||
import { AdminSidebar } from '@/components/admin/AdminSidebar'
|
||||
import { AdminHeader } from '@/components/admin/AdminHeader'
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user || session.user.role !== 'ADMIN') {
|
||||
redirect('/auth/signin?callbackUrl=/admin')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<AdminHeader user={session.user} />
|
||||
<div className="flex">
|
||||
<AdminSidebar />
|
||||
<main className="flex-1 p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
135
app/admin/loading.tsx
Normal file
135
app/admin/loading.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { Settings, Shield, BarChart3 } from 'lucide-react'
|
||||
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<div className="min-h-[70vh] flex items-center justify-center px-4">
|
||||
<div className="text-center">
|
||||
{/* Admin Loading Animation */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="relative mx-auto w-24 h-24 mb-4">
|
||||
{/* Outer ring */}
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
duration: 3,
|
||||
repeat: Infinity,
|
||||
ease: "linear"
|
||||
}}
|
||||
className="absolute inset-0 border-3 border-blue-200 border-t-blue-600 rounded-full"
|
||||
/>
|
||||
|
||||
{/* Inner ring */}
|
||||
<motion.div
|
||||
animate={{ rotate: -360 }}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "linear"
|
||||
}}
|
||||
className="absolute inset-3 border-2 border-purple-200 border-b-purple-500 rounded-full"
|
||||
/>
|
||||
|
||||
{/* Center icon */}
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.15, 1],
|
||||
rotate: [0, 180, 360]
|
||||
}}
|
||||
transition={{
|
||||
duration: 4,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
className="absolute inset-6 flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50 rounded-full shadow-sm"
|
||||
>
|
||||
<Settings className="w-6 h-6 text-blue-600" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Loading Text */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="mb-4"
|
||||
>
|
||||
<div className="flex items-center justify-center space-x-2 text-blue-600 font-semibold text-lg">
|
||||
<Shield className="w-5 h-5" />
|
||||
<span>Loading Admin Panel</span>
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm mt-1">
|
||||
Preparing dashboard data...
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Progress indicators */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="flex justify-center space-x-2"
|
||||
>
|
||||
{[0, 1, 2, 3].map((index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="w-2 h-2 bg-blue-500 rounded-full"
|
||||
animate={{
|
||||
scale: [1, 1.4, 1],
|
||||
opacity: [0.3, 1, 0.3]
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.8,
|
||||
repeat: Infinity,
|
||||
delay: index * 0.2,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Stats preview */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.6 }}
|
||||
className="mt-8 grid grid-cols-3 gap-4 max-w-sm mx-auto"
|
||||
>
|
||||
{[
|
||||
{ icon: BarChart3, label: 'Analytics' },
|
||||
{ icon: Settings, label: 'Settings' },
|
||||
{ icon: Shield, label: 'Security' }
|
||||
].map((item, index) => {
|
||||
const IconComponent = item.icon
|
||||
return (
|
||||
<motion.div
|
||||
key={item.label}
|
||||
animate={{
|
||||
opacity: [0.3, 1, 0.3]
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
delay: index * 0.3,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
className="text-center p-3 bg-white rounded-lg shadow-sm border border-slate-100"
|
||||
>
|
||||
<IconComponent className="w-6 h-6 text-slate-400 mx-auto mb-1" />
|
||||
<span className="text-xs text-slate-500">{item.label}</span>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
459
app/admin/orders/[id]/page.tsx
Normal file
459
app/admin/orders/[id]/page.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Package,
|
||||
User,
|
||||
CreditCard,
|
||||
Truck,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
MapPin,
|
||||
Phone,
|
||||
Mail
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
import { motion } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface OrderItem {
|
||||
id: string
|
||||
quantity: number
|
||||
price: number
|
||||
product: {
|
||||
id: string
|
||||
name: string
|
||||
images: string[]
|
||||
sku: string
|
||||
price: number
|
||||
}
|
||||
}
|
||||
|
||||
interface ShippingAddress {
|
||||
firstName: string
|
||||
lastName: string
|
||||
company?: string | null
|
||||
address1: string
|
||||
address2?: string | null
|
||||
city: string
|
||||
state: string
|
||||
zipCode: string
|
||||
country: string
|
||||
phone?: string | null
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: string
|
||||
total: number
|
||||
status: 'PENDING' | 'PAID' | 'SHIPPED' | 'DELIVERED' | 'CANCELLED'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
razorpayOrderId: string | null
|
||||
razorpayPaymentId: string | null
|
||||
shippingAddress: ShippingAddress | null
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
phone: string | null
|
||||
}
|
||||
orderItems: OrderItem[]
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
PENDING: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
PAID: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
SHIPPED: 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
DELIVERED: 'bg-green-100 text-green-800 border-green-200',
|
||||
CANCELLED: 'bg-red-100 text-red-800 border-red-200'
|
||||
}
|
||||
|
||||
const statusIcons = {
|
||||
PENDING: Clock,
|
||||
PAID: CreditCard,
|
||||
SHIPPED: Truck,
|
||||
DELIVERED: CheckCircle,
|
||||
CANCELLED: XCircle
|
||||
}
|
||||
|
||||
export default function AdminOrderDetail() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const [order, setOrder] = useState<Order | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
|
||||
const orderId = params.id as string
|
||||
|
||||
const fetchOrder = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch(`/api/admin/orders/${orderId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch order')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setOrder(data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching order:', error)
|
||||
toast.error('Failed to load order details')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [orderId])
|
||||
|
||||
const updateOrderStatus = async (newStatus: string) => {
|
||||
try {
|
||||
setUpdating(true)
|
||||
const response = await fetch(`/api/admin/orders/${orderId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update order status')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setOrder(data)
|
||||
toast.success('Order status updated successfully')
|
||||
} catch (error) {
|
||||
console.error('Error updating order:', error)
|
||||
toast.error('Failed to update order status')
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (orderId) {
|
||||
fetchOrder()
|
||||
}
|
||||
}, [orderId, fetchOrder])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="h-96 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="h-48 bg-gray-200 rounded"></div>
|
||||
<div className="h-32 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Package className="h-12 w-12 text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Order Not Found</h3>
|
||||
<p className="text-gray-600 mb-4">The order you're looking for doesn't exist.</p>
|
||||
<Link href="/admin/orders">
|
||||
<Button>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Orders
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const StatusIcon = statusIcons[order.status] || Clock
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="container mx-auto px-4 py-8"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/admin/orders">
|
||||
<Button variant="outline" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Orders
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Order #{order.id}</h1>
|
||||
<p className="text-gray-600">
|
||||
Placed on {new Date(order.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={statusColors[order.status]}>
|
||||
<StatusIcon className="h-4 w-4 mr-1" />
|
||||
{order.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Order Items */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Package className="h-5 w-5 mr-2" />
|
||||
Order Items ({order.orderItems?.length || 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{order.orderItems && order.orderItems.length > 0 ? (
|
||||
order.orderItems.map((item) => (
|
||||
<div key={item.id} className="flex items-center space-x-4 p-4 border rounded-lg bg-gray-50">
|
||||
<div className="w-16 h-16 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||
{item.product?.images && item.product.images.length > 0 ? (
|
||||
<Image
|
||||
src={item.product.images[0]}
|
||||
alt={item.product.name || 'Product'}
|
||||
width={64}
|
||||
height={64}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<Package className="h-8 w-8 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">{item.product?.name || 'Unknown Product'}</h4>
|
||||
<p className="text-sm text-gray-600">SKU: {item.product?.sku || 'N/A'}</p>
|
||||
<div className="flex items-center space-x-4 mt-1">
|
||||
<span className="text-sm text-gray-600">Qty: {item.quantity}</span>
|
||||
<span className="text-sm text-gray-600">Price: ₹{item.price.toFixed(2)} each</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-gray-900">
|
||||
₹{(item.price * item.quantity).toFixed(2)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{item.quantity} × ₹{item.price.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No items found in this order</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-semibold">Total Amount:</span>
|
||||
<span className="text-xl font-bold text-green-600">₹{order.total?.toFixed(2) || '0.00'}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Order Details Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Order Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Order Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 mb-2 block">
|
||||
Update Order Status
|
||||
</label>
|
||||
<Select
|
||||
value={order.status}
|
||||
onValueChange={(value) => updateOrderStatus(value)}
|
||||
disabled={updating}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PENDING">
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-4 w-4 mr-2 text-yellow-600" />
|
||||
Pending
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="PAID">
|
||||
<div className="flex items-center">
|
||||
<CreditCard className="h-4 w-4 mr-2 text-blue-600" />
|
||||
Paid
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="SHIPPED">
|
||||
<div className="flex items-center">
|
||||
<Truck className="h-4 w-4 mr-2 text-purple-600" />
|
||||
Shipped
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="DELIVERED">
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-4 w-4 mr-2 text-green-600" />
|
||||
Delivered
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="CANCELLED">
|
||||
<div className="flex items-center">
|
||||
<XCircle className="h-4 w-4 mr-2 text-red-600" />
|
||||
Cancelled
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{updating && (
|
||||
<div className="text-sm text-gray-500 flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div>
|
||||
Updating order status...
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Customer Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<User className="h-5 w-5 mr-2" />
|
||||
Customer Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<User className="h-4 w-4 text-gray-500" />
|
||||
<span>{order.user?.name || 'Unknown User'}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Mail className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm">{order.user?.email || 'No email'}</span>
|
||||
</div>
|
||||
{order.user?.phone && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<Phone className="h-4 w-4 text-gray-500" />
|
||||
<span>{order.user.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Shipping Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<MapPin className="h-5 w-5 mr-2" />
|
||||
Shipping Address
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{order.shippingAddress ? (
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold">
|
||||
{order.shippingAddress.firstName} {order.shippingAddress.lastName}
|
||||
</p>
|
||||
{order.shippingAddress.company && (
|
||||
<p className="text-sm text-gray-600">{order.shippingAddress.company}</p>
|
||||
)}
|
||||
<p className="text-sm">{order.shippingAddress.address1}</p>
|
||||
{order.shippingAddress.address2 && (
|
||||
<p className="text-sm">{order.shippingAddress.address2}</p>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
{order.shippingAddress.city}, {order.shippingAddress.state} {order.shippingAddress.zipCode}
|
||||
</p>
|
||||
<p className="text-sm">{order.shippingAddress.country}</p>
|
||||
{order.shippingAddress.phone && (
|
||||
<p className="text-sm flex items-center mt-2">
|
||||
<Phone className="h-4 w-4 mr-1" />
|
||||
{order.shippingAddress.phone}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No shipping address provided</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Payment Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<CreditCard className="h-5 w-5 mr-2" />
|
||||
Payment Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">Payment Status:</span>
|
||||
<Badge className={`ml-2 ${statusColors[order.status]}`}>
|
||||
{order.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{order.razorpayOrderId && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">Razorpay Order ID:</span>
|
||||
<p className="text-sm font-mono bg-gray-50 p-2 rounded mt-1">{order.razorpayOrderId}</p>
|
||||
</div>
|
||||
)}
|
||||
{order.razorpayPaymentId && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">Payment ID:</span>
|
||||
<p className="text-sm font-mono bg-gray-50 p-2 rounded mt-1">{order.razorpayPaymentId}</p>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500">Total Amount:</span>
|
||||
<p className="text-lg font-bold text-green-600">₹{order.total?.toFixed(2) || '0.00'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
484
app/admin/orders/page.tsx
Normal file
484
app/admin/orders/page.tsx
Normal file
@@ -0,0 +1,484 @@
|
||||
'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 { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Search, Download, MoreHorizontal, Eye, Package, Truck, CheckCircle, XCircle, Clock, CreditCard, Filter } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface Order {
|
||||
id: string
|
||||
total: number
|
||||
status: 'PENDING' | 'PAID' | 'SHIPPED' | 'DELIVERED' | 'CANCELLED'
|
||||
createdAt: string
|
||||
razorpayOrderId: string | null
|
||||
razorpayPaymentId: string | null
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
orderItems: {
|
||||
id: string
|
||||
quantity: number
|
||||
price: number
|
||||
product: {
|
||||
id: string
|
||||
name: string
|
||||
images: string[]
|
||||
}
|
||||
}[]
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
PENDING: {
|
||||
label: 'Pending',
|
||||
color: 'bg-gradient-to-r from-yellow-400 to-orange-500',
|
||||
icon: Clock,
|
||||
description: 'Awaiting payment'
|
||||
},
|
||||
PAID: {
|
||||
label: 'Paid',
|
||||
color: 'bg-gradient-to-r from-blue-500 to-indigo-600',
|
||||
icon: CreditCard,
|
||||
description: 'Payment confirmed'
|
||||
},
|
||||
SHIPPED: {
|
||||
label: 'Shipped',
|
||||
color: 'bg-gradient-to-r from-purple-500 to-pink-600',
|
||||
icon: Truck,
|
||||
description: 'Order shipped'
|
||||
},
|
||||
DELIVERED: {
|
||||
label: 'Delivered',
|
||||
color: 'bg-gradient-to-r from-green-500 to-emerald-600',
|
||||
icon: CheckCircle,
|
||||
description: 'Successfully delivered'
|
||||
},
|
||||
CANCELLED: {
|
||||
label: 'Cancelled',
|
||||
color: 'bg-gradient-to-r from-red-500 to-pink-600',
|
||||
icon: XCircle,
|
||||
description: 'Order cancelled'
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminOrdersPage() {
|
||||
const [orders, setOrders] = useState<Order[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [selectedOrders, setSelectedOrders] = useState<string[]>([])
|
||||
const [bulkActionLoading, setBulkActionLoading] = useState(false)
|
||||
|
||||
const fetchOrders = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (search) params.append('search', search)
|
||||
if (statusFilter && statusFilter !== 'all') params.append('status', statusFilter)
|
||||
|
||||
const response = await fetch(`/api/admin/orders?${params}`)
|
||||
const data = await response.json()
|
||||
setOrders(data || [])
|
||||
} catch (error) {
|
||||
console.error('Error fetching orders:', error)
|
||||
toast.error('Failed to load orders')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [search, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders()
|
||||
}, [fetchOrders])
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedOrders(orders.map(o => o.id))
|
||||
} else {
|
||||
setSelectedOrders([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectOrder = (orderId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedOrders(prev => [...prev, orderId])
|
||||
} else {
|
||||
setSelectedOrders(prev => prev.filter(id => id !== orderId))
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkStatusUpdate = async (newStatus: string) => {
|
||||
if (selectedOrders.length === 0) {
|
||||
toast.error('Please select at least one order')
|
||||
return
|
||||
}
|
||||
|
||||
setBulkActionLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/admin/orders/bulk', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
orderIds: selectedOrders,
|
||||
status: newStatus
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Bulk update failed')
|
||||
|
||||
toast.success(`Successfully updated ${selectedOrders.length} orders`)
|
||||
setSelectedOrders([])
|
||||
fetchOrders()
|
||||
} catch (error) {
|
||||
toast.error('Failed to update orders')
|
||||
} finally {
|
||||
setBulkActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusUpdate = async (orderId: string, newStatus: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/orders/${orderId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Status update failed')
|
||||
|
||||
toast.success('Order status updated successfully')
|
||||
fetchOrders()
|
||||
} catch (error) {
|
||||
toast.error('Failed to update order status')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-100 p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-6"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-transparent">
|
||||
Orders Management
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">Manage customer orders and track shipments</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="outline" size="sm" className="bg-white shadow-md hover:shadow-lg transition-shadow">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export Data
|
||||
</Button>
|
||||
<Button className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 shadow-lg">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Advanced Filters
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6"
|
||||
>
|
||||
{Object.entries(statusConfig).map(([status, config], index) => {
|
||||
const StatusIcon = config.icon
|
||||
const count = orders.filter(order => order.status === status).length
|
||||
return (
|
||||
<Card key={status} className="bg-white/80 backdrop-blur-sm border-0 shadow-lg hover:shadow-xl transition-all duration-300">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">{config.label}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{count}</p>
|
||||
</div>
|
||||
<div className={`w-12 h-12 ${config.color} rounded-xl flex items-center justify-center shadow-lg`}>
|
||||
<StatusIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">{config.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
{/* Main Orders Table */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-xl">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4">
|
||||
<CardTitle className="text-2xl font-bold">All Orders</CardTitle>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{selectedOrders.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex items-center space-x-3 bg-blue-50 px-4 py-2 rounded-lg border border-blue-200"
|
||||
>
|
||||
<span className="text-sm font-medium text-blue-700">
|
||||
{selectedOrders.length} selected
|
||||
</span>
|
||||
<Select onValueChange={handleBulkStatusUpdate}>
|
||||
<SelectTrigger className="w-40 bg-white">
|
||||
<SelectValue placeholder="Update Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PAID">Mark as Paid</SelectItem>
|
||||
<SelectItem value="SHIPPED">Mark as Shipped</SelectItem>
|
||||
<SelectItem value="DELIVERED">Mark as Delivered</SelectItem>
|
||||
<SelectItem value="CANCELLED">Mark as Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</motion.div>
|
||||
)}
|
||||
<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 orders, customers..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 w-64 bg-white shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-40 bg-white shadow-sm">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="PENDING">Pending</SelectItem>
|
||||
<SelectItem value="PAID">Paid</SelectItem>
|
||||
<SelectItem value="SHIPPED">Shipped</SelectItem>
|
||||
<SelectItem value="DELIVERED">Delivered</SelectItem>
|
||||
<SelectItem value="CANCELLED">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 border-4 border-gray-200 border-t-blue-500 rounded-full animate-spin"></div>
|
||||
<div className="w-10 h-10 border-4 border-gray-100 border-t-purple-500 rounded-full animate-spin absolute top-1 left-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedOrders.length === orders.length && orders.length > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="font-semibold">Order</TableHead>
|
||||
<TableHead className="font-semibold">Customer</TableHead>
|
||||
<TableHead className="font-semibold">Items</TableHead>
|
||||
<TableHead className="font-semibold">Total</TableHead>
|
||||
<TableHead className="font-semibold">Status</TableHead>
|
||||
<TableHead className="font-semibold">Date</TableHead>
|
||||
<TableHead className="font-semibold">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orders.map((order, index) => {
|
||||
const StatusIcon = statusConfig[order.status].icon
|
||||
return (
|
||||
<motion.tr
|
||||
key={order.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedOrders.includes(order.id)}
|
||||
onCheckedChange={(checked) => handleSelectOrder(order.id, !!checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-gray-900">#{order.id.slice(-8)}</p>
|
||||
{order.razorpayOrderId && (
|
||||
<p className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded">
|
||||
Razorpay: {order.razorpayOrderId.slice(-8)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-r from-blue-500 to-purple-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-sm font-medium">
|
||||
{order.user.name?.charAt(0) || 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{order.user.name}</p>
|
||||
<p className="text-sm text-gray-500">{order.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
{order.orderItems.slice(0, 2).map((item) => (
|
||||
<div key={item.id} className="text-sm">
|
||||
<span className="font-medium">{item.quantity}x</span>{' '}
|
||||
<span className="text-gray-600">{item.product.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{order.orderItems.length > 2 && (
|
||||
<p className="text-xs text-blue-600 font-medium">
|
||||
+{order.orderItems.length - 2} more items
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-bold text-lg bg-gradient-to-r from-green-600 to-emerald-600 bg-clip-text text-transparent">
|
||||
₹{order.total.toFixed(2)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${statusConfig[order.status].color} text-white shadow-md`}>
|
||||
<StatusIcon className="h-3 w-3 mr-1" />
|
||||
{statusConfig[order.status].label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-gray-900">
|
||||
{new Date(order.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
<p className="text-gray-500">
|
||||
{new Date(order.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="hover:bg-gray-100">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/orders/${order.id}`} className="flex items-center">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{order.status === 'PENDING' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusUpdate(order.id, 'PAID')}
|
||||
className="text-blue-600"
|
||||
>
|
||||
<CreditCard className="h-4 w-4 mr-2" />
|
||||
Mark as Paid
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{order.status === 'PAID' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusUpdate(order.id, 'SHIPPED')}
|
||||
className="text-purple-600"
|
||||
>
|
||||
<Truck className="h-4 w-4 mr-2" />
|
||||
Mark as Shipped
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{order.status === 'SHIPPED' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusUpdate(order.id, 'DELIVERED')}
|
||||
className="text-green-600"
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Mark as Delivered
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{['PENDING', 'PAID'].includes(order.status) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusUpdate(order.id, 'CANCELLED')}
|
||||
className="text-red-600"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
Cancel Order
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && orders.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Package className="h-10 w-10 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No orders found</h3>
|
||||
<p className="text-gray-500">Try adjusting your search or filter criteria</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
253
app/admin/page.tsx
Normal file
253
app/admin/page.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Users, Package, ShoppingCart, DollarSign, TrendingUp, TrendingDown } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface DashboardStats {
|
||||
totalUsers: number
|
||||
totalProducts: number
|
||||
totalOrders: number
|
||||
totalRevenue: number
|
||||
userGrowth: number
|
||||
productGrowth: number
|
||||
orderGrowth: number
|
||||
revenueGrowth: number
|
||||
}
|
||||
|
||||
interface RecentOrder {
|
||||
id: string
|
||||
total: number
|
||||
status: string
|
||||
createdAt: string
|
||||
user: {
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
}
|
||||
|
||||
interface TopProduct {
|
||||
id: string
|
||||
name: string
|
||||
price: number
|
||||
orderCount: number
|
||||
totalRevenue: number
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [recentOrders, setRecentOrders] = useState<RecentOrder[]>([])
|
||||
const [topProducts, setTopProducts] = useState<TopProduct[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData()
|
||||
}, [])
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const [statsRes, ordersRes, productsRes] = await Promise.all([
|
||||
fetch('/api/admin/dashboard/stats'),
|
||||
fetch('/api/admin/dashboard/recent-orders'),
|
||||
fetch('/api/admin/dashboard/top-products')
|
||||
])
|
||||
|
||||
const [statsData, ordersData, productsData] = await Promise.all([
|
||||
statsRes.json(),
|
||||
ordersRes.json(),
|
||||
productsRes.json()
|
||||
])
|
||||
|
||||
setStats(statsData)
|
||||
setRecentOrders(ordersData.orders || [])
|
||||
setTopProducts(productsData.products || [])
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'PENDING': return 'bg-yellow-100 text-yellow-800'
|
||||
case 'PAID': return 'bg-blue-100 text-blue-800'
|
||||
case 'SHIPPED': return 'bg-purple-100 text-purple-800'
|
||||
case 'DELIVERED': return 'bg-green-100 text-green-800'
|
||||
case 'CANCELLED': return 'bg-red-100 text-red-800'
|
||||
default: return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const dashboardStats = stats ? [
|
||||
{
|
||||
title: 'Total Users',
|
||||
value: stats.totalUsers.toLocaleString(),
|
||||
change: `${stats.userGrowth >= 0 ? '+' : ''}${stats.userGrowth.toFixed(1)}%`,
|
||||
trend: stats.userGrowth >= 0 ? 'up' : 'down',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: 'Products',
|
||||
value: stats.totalProducts.toLocaleString(),
|
||||
change: `${stats.productGrowth >= 0 ? '+' : ''}${stats.productGrowth.toFixed(1)}%`,
|
||||
trend: stats.productGrowth >= 0 ? 'up' : 'down',
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: 'Orders',
|
||||
value: stats.totalOrders.toLocaleString(),
|
||||
change: `${stats.orderGrowth >= 0 ? '+' : ''}${stats.orderGrowth.toFixed(1)}%`,
|
||||
trend: stats.orderGrowth >= 0 ? 'up' : 'down',
|
||||
icon: ShoppingCart,
|
||||
},
|
||||
{
|
||||
title: 'Revenue',
|
||||
value: `₹${stats.totalRevenue.toLocaleString()}`,
|
||||
change: `${stats.revenueGrowth >= 0 ? '+' : ''}${stats.revenueGrowth.toFixed(1)}%`,
|
||||
trend: stats.revenueGrowth >= 0 ? 'up' : 'down',
|
||||
icon: DollarSign,
|
||||
},
|
||||
] : []
|
||||
|
||||
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">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Admin Dashboard</h1>
|
||||
<p className="text-gray-600 mt-2">Welcome to your admin dashboard</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{dashboardStats.map((stat, index) => (
|
||||
<motion.div
|
||||
key={stat.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<Card className="bg-white/80 backdrop-blur-sm shadow-lg border-0">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">
|
||||
{stat.title}
|
||||
</CardTitle>
|
||||
<stat.icon className="h-5 w-5 text-gray-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-gray-900">{stat.value}</div>
|
||||
<p className="text-xs text-muted-foreground flex items-center mt-2">
|
||||
{stat.trend === 'up' ? (
|
||||
<TrendingUp className="h-3 w-3 text-green-500 mr-1" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3 text-red-500 mr-1" />
|
||||
)}
|
||||
<span className={stat.trend === 'up' ? 'text-green-600 font-medium' : 'text-red-600 font-medium'}>
|
||||
{stat.change}
|
||||
</span>
|
||||
<span className="ml-1 text-gray-500">from last month</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
{/* Recent Orders */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<Card className="bg-white/80 backdrop-blur-sm shadow-lg border-0">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Orders</CardTitle>
|
||||
<CardDescription>Latest orders from your store</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentOrders.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<ShoppingCart className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No recent orders</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{recentOrders.map((order) => (
|
||||
<div key={order.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<p className="font-medium">Order #{order.id.slice(-8)}</p>
|
||||
<Badge className={getStatusColor(order.status)}>
|
||||
{order.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{order.user.name}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{new Date(order.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-bold text-green-600">₹{order.total.toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Top Products */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<Card className="bg-white/80 backdrop-blur-sm shadow-lg border-0">
|
||||
<CardHeader>
|
||||
<CardTitle>Top Products</CardTitle>
|
||||
<CardDescription>Best selling products this month</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{topProducts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No product data available</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{topProducts.map((product, index) => (
|
||||
<div key={product.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-blue-600">#{index + 1}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{product.name}</p>
|
||||
<p className="text-sm text-gray-500">{product.orderCount} sold</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-gray-900">₹{product.price.toFixed(2)}</p>
|
||||
<p className="text-xs text-green-600">₹{product.totalRevenue.toFixed(0)} revenue</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
84
app/admin/products/[id]/edit/page.tsx
Normal file
84
app/admin/products/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { ProductForm } from '@/components/admin/ProductForm'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface Product {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
price: number
|
||||
discount: number
|
||||
images: string[]
|
||||
stock: number
|
||||
manageStock: boolean
|
||||
sku: string
|
||||
isActive: boolean
|
||||
categoryId: string
|
||||
category: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export default function EditProductPage() {
|
||||
const params = useParams()
|
||||
const [product, setProduct] = useState<Product | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (params.id) {
|
||||
fetchProduct(params.id as string)
|
||||
}
|
||||
}, [params.id])
|
||||
|
||||
const fetchProduct = async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/products/${id}`)
|
||||
if (!response.ok) throw new Error('Product not found')
|
||||
|
||||
const data = await response.json()
|
||||
// Transform the data to match the form's expected structure
|
||||
const transformedProduct = {
|
||||
...data,
|
||||
categoryId: data.category.id
|
||||
}
|
||||
setProduct(transformedProduct)
|
||||
} catch (error) {
|
||||
console.error('Error fetching product:', error)
|
||||
toast.error('Failed to load product')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Product not found</h2>
|
||||
<p className="text-gray-600">The product you're looking for doesn't exist.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Edit Product</h1>
|
||||
<p className="text-gray-600">Update product information</p>
|
||||
</div>
|
||||
|
||||
<ProductForm product={product} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
app/admin/products/new/page.tsx
Normal file
14
app/admin/products/new/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ProductForm } from '@/components/admin/ProductForm'
|
||||
|
||||
export default function NewProductPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Add New Product</h1>
|
||||
<p className="text-gray-600">Create a new product in your catalog</p>
|
||||
</div>
|
||||
|
||||
<ProductForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
653
app/admin/products/page.tsx
Normal file
653
app/admin/products/page.tsx
Normal file
@@ -0,0 +1,653 @@
|
||||
'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 { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import CsvImport from '@/components/ui/csv-import'
|
||||
import CsvExport from '@/components/ui/csv-export'
|
||||
import { Search, Plus, Edit, Trash2, MoreHorizontal, Download, Save, Upload } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface Product {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
price: number
|
||||
discount: number
|
||||
stock: number
|
||||
isActive: boolean
|
||||
images: string[]
|
||||
sku: string
|
||||
weight: string | null
|
||||
categoryId: string
|
||||
category: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
interface QuickEditFormData {
|
||||
name: string
|
||||
description: string
|
||||
price: number
|
||||
discount: number
|
||||
stock: number
|
||||
weight: string
|
||||
}
|
||||
|
||||
export default function AdminProductsPage() {
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedProducts, setSelectedProducts] = useState<string[]>([])
|
||||
const [bulkActionLoading, setBulkActionLoading] = useState(false)
|
||||
const [quickEditOpen, setQuickEditOpen] = useState(false)
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null)
|
||||
const [quickEditForm, setQuickEditForm] = useState<QuickEditFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
discount: 0,
|
||||
stock: 0,
|
||||
weight: ''
|
||||
})
|
||||
const [quickEditLoading, setQuickEditLoading] = useState(false)
|
||||
|
||||
const fetchProducts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams({
|
||||
page: '1',
|
||||
limit: '20',
|
||||
admin: 'true' // Add admin flag to get all products
|
||||
})
|
||||
|
||||
if (search) params.append('search', search)
|
||||
|
||||
const response = await fetch(`/api/products?${params}`)
|
||||
const data = await response.json()
|
||||
setProducts(data.products || [])
|
||||
} catch (error) {
|
||||
console.error('Error fetching products:', error)
|
||||
toast.error('Failed to load products')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [search])
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts()
|
||||
}, [fetchProducts])
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedProducts(products.map(p => p.id))
|
||||
} else {
|
||||
setSelectedProducts([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectProduct = (productId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedProducts(prev => [...prev, productId])
|
||||
} else {
|
||||
setSelectedProducts(prev => prev.filter(id => id !== productId))
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkAction = async (action: 'activate' | 'deactivate' | 'delete') => {
|
||||
if (selectedProducts.length === 0) {
|
||||
toast.error('Please select at least one product')
|
||||
return
|
||||
}
|
||||
|
||||
setBulkActionLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/admin/products/bulk', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
productIds: selectedProducts,
|
||||
action
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Bulk action failed')
|
||||
|
||||
toast.success(`Successfully ${action}d ${selectedProducts.length} products`)
|
||||
setSelectedProducts([])
|
||||
fetchProducts()
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${action} products`)
|
||||
} finally {
|
||||
setBulkActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteProduct = async (productId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this product?')) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/products/${productId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Delete failed')
|
||||
|
||||
toast.success('Product deleted successfully')
|
||||
fetchProducts()
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete product')
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickEdit = (product: Product) => {
|
||||
setEditingProduct(product)
|
||||
setQuickEditForm({
|
||||
name: product.name,
|
||||
description: product.description || '',
|
||||
price: product.price,
|
||||
discount: product.discount,
|
||||
stock: product.stock,
|
||||
weight: product.weight || ''
|
||||
})
|
||||
setQuickEditOpen(true)
|
||||
}
|
||||
|
||||
const handleQuickEditSave = async () => {
|
||||
if (!editingProduct) return
|
||||
|
||||
setQuickEditLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/admin/products/${editingProduct.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: quickEditForm.name,
|
||||
description: quickEditForm.description,
|
||||
price: quickEditForm.price,
|
||||
discount: quickEditForm.discount,
|
||||
stock: quickEditForm.stock,
|
||||
weight: quickEditForm.weight,
|
||||
// Keep existing values that aren't being edited
|
||||
images: editingProduct.images,
|
||||
sku: editingProduct.sku,
|
||||
isActive: editingProduct.isActive,
|
||||
categoryId: editingProduct.categoryId || editingProduct.category.id
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Update failed')
|
||||
}
|
||||
|
||||
toast.success('Product updated successfully')
|
||||
setQuickEditOpen(false)
|
||||
setEditingProduct(null)
|
||||
fetchProducts()
|
||||
} catch (error) {
|
||||
console.error('Quick edit error:', error)
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update product')
|
||||
} finally {
|
||||
setQuickEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickEditInputChange = (field: keyof QuickEditFormData, value: string | number) => {
|
||||
setQuickEditForm(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Products</h1>
|
||||
<p className="text-gray-600">Manage your product catalog</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CsvExport
|
||||
title="Export Products"
|
||||
description="Export products to a CSV file. Select the columns you want to include."
|
||||
columns={[
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'price', label: 'Price' },
|
||||
{ key: 'category.name', label: 'Category' },
|
||||
{ key: 'stock', label: 'Stock' },
|
||||
{ key: 'active', label: 'Status' },
|
||||
{ key: 'createdAt', label: 'Created At' }
|
||||
]}
|
||||
onExport={async (selectedColumns, filters, onProgress) => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/products/export', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
columns: selectedColumns,
|
||||
filters
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Export failed')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
data: result.data || products,
|
||||
message: 'Export completed successfully'
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Export failed',
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</CsvExport>
|
||||
|
||||
<CsvImport
|
||||
title="Import Products"
|
||||
description="Import products from a CSV file. Download the template to see the required format."
|
||||
templateColumns={[
|
||||
{ key: 'name', label: 'Name', required: true },
|
||||
{ key: 'description', label: 'Description', required: true },
|
||||
{ key: 'price', label: 'Price', required: true },
|
||||
{ key: 'categoryId', label: 'Category ID', required: true },
|
||||
{ key: 'stock', label: 'Stock', required: false },
|
||||
{ key: 'active', label: 'Active', required: false }
|
||||
]}
|
||||
onImport={async (data, onProgress) => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/products/import', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ data }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Import failed')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
fetchProducts() // Refresh the products list
|
||||
return result
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Import failed',
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
</CsvImport>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/admin/products/new">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Product
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>All Products</CardTitle>
|
||||
<div className="flex items-center space-x-2">
|
||||
{selectedProducts.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-500">
|
||||
{selectedProducts.length} selected
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('activate')}
|
||||
disabled={bulkActionLoading}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('deactivate')}
|
||||
disabled={bulkActionLoading}
|
||||
>
|
||||
Deactivate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('delete')}
|
||||
disabled={bulkActionLoading}
|
||||
className="text-red-600"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<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 products..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 w-64"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="text-center py-8">Loading...</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-white z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedProducts.length === products.length && products.length > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead>SKU</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Price</TableHead>
|
||||
<TableHead>Stock</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
<div className="max-h-[55vh] overflow-y-auto">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{products.map((product) => (
|
||||
<TableRow key={product.id}>
|
||||
<TableCell className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedProducts.includes(product.id)}
|
||||
onCheckedChange={(checked) => handleSelectProduct(product.id, !!checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Image
|
||||
src={product.images[0] || 'https://images.pexels.com/photos/3683107/pexels-photo-3683107.jpeg'}
|
||||
alt={product.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-lg object-cover"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium">{product.name}</p>
|
||||
{product.discount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{product.discount}% OFF
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{product.sku}</TableCell>
|
||||
<TableCell>{product.category.name}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
{product.discount > 0 ? (
|
||||
<>
|
||||
<span className="font-bold text-green-600">
|
||||
₹{(product.price - (product.price * product.discount / 100)).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 line-through ml-2">
|
||||
₹{product.price.toFixed(2)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="font-bold">₹{product.price.toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={product.stock > 10 ? 'default' : product.stock > 0 ? 'secondary' : 'destructive'}>
|
||||
{product.stock}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={product.isActive ? 'default' : 'secondary'}>
|
||||
{product.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleQuickEdit(product)}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Edit className="h-3 w-3 mr-1" />
|
||||
Quick Edit
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/products/${product.id}/edit`}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Full Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteProduct(product.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Edit Dialog */}
|
||||
<Dialog open={quickEditOpen} onOpenChange={setQuickEditOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Quick Edit Product</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{editingProduct && (
|
||||
<div className="space-y-6">
|
||||
{/* Product Image and Basic Info */}
|
||||
<div className="flex items-start space-x-4">
|
||||
<Image
|
||||
src={editingProduct.images[0] || 'https://images.pexels.com/photos/3683107/pexels-photo-3683107.jpeg'}
|
||||
alt={editingProduct.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-500">SKU: {editingProduct.sku}</p>
|
||||
<p className="text-sm text-gray-500">Category: {editingProduct.category.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quick-name">Product Name</Label>
|
||||
<Input
|
||||
id="quick-name"
|
||||
value={quickEditForm.name}
|
||||
onChange={(e) => handleQuickEditInputChange('name', e.target.value)}
|
||||
placeholder="Product name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quick-weight">Weight</Label>
|
||||
<Input
|
||||
id="quick-weight"
|
||||
value={quickEditForm.weight}
|
||||
onChange={(e) => handleQuickEditInputChange('weight', e.target.value)}
|
||||
placeholder="e.g., 1kg, 500g"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quick-price">Price (₹)</Label>
|
||||
<Input
|
||||
id="quick-price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={quickEditForm.price}
|
||||
onChange={(e) => handleQuickEditInputChange('price', parseFloat(e.target.value) || 0)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quick-discount">Discount (%)</Label>
|
||||
<Input
|
||||
id="quick-discount"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={quickEditForm.discount}
|
||||
onChange={(e) => handleQuickEditInputChange('discount', parseFloat(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quick-stock">Stock Quantity</Label>
|
||||
<Input
|
||||
id="quick-stock"
|
||||
type="number"
|
||||
min="0"
|
||||
value={quickEditForm.stock}
|
||||
onChange={(e) => handleQuickEditInputChange('stock', parseInt(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quick-discounted-price">Final Price</Label>
|
||||
<div className="p-2 bg-gray-50 rounded-md text-sm">
|
||||
₹{(quickEditForm.price - (quickEditForm.price * quickEditForm.discount / 100)).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quick-description">Description</Label>
|
||||
<Textarea
|
||||
id="quick-description"
|
||||
value={quickEditForm.description}
|
||||
onChange={(e) => handleQuickEditInputChange('description', e.target.value)}
|
||||
placeholder="Product description"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end space-x-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setQuickEditOpen(false)}
|
||||
disabled={quickEditLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleQuickEditSave}
|
||||
disabled={quickEditLoading}
|
||||
>
|
||||
{quickEditLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
304
app/admin/reviews/page.tsx
Normal file
304
app/admin/reviews/page.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
'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 { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Star, Search, Check, X, Eye, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface Review {
|
||||
id: string
|
||||
rating: number
|
||||
title?: string
|
||||
comment?: string
|
||||
isVerified: boolean
|
||||
isApproved: boolean
|
||||
helpfulVotes: number
|
||||
reportCount: number
|
||||
createdAt: string
|
||||
user: {
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
product: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminReviewsPage() {
|
||||
const [reviews, setReviews] = useState<Review[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'approved'>('all')
|
||||
|
||||
const fetchReviews = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams({
|
||||
page: '1',
|
||||
limit: '50',
|
||||
admin: 'true'
|
||||
})
|
||||
|
||||
if (search) params.append('search', search)
|
||||
if (statusFilter !== 'all') {
|
||||
params.append('approved', statusFilter === 'approved' ? 'true' : 'false')
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/admin/reviews?${params}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
setReviews(data.reviews || [])
|
||||
} else {
|
||||
toast.error('Failed to load reviews')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to load reviews')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [search, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchReviews()
|
||||
}, [fetchReviews])
|
||||
|
||||
const handleApprove = async (reviewId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/reviews/${reviewId}/approve`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Review approved')
|
||||
setReviews(prev =>
|
||||
prev.map(review =>
|
||||
review.id === reviewId
|
||||
? { ...review, isApproved: true }
|
||||
: review
|
||||
)
|
||||
)
|
||||
} else {
|
||||
toast.error('Failed to approve review')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to approve review')
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = async (reviewId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/reviews/${reviewId}/reject`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Review rejected')
|
||||
setReviews(prev =>
|
||||
prev.map(review =>
|
||||
review.id === reviewId
|
||||
? { ...review, isApproved: false }
|
||||
: review
|
||||
)
|
||||
)
|
||||
} else {
|
||||
toast.error('Failed to reject review')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to reject review')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (reviewId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this review?')) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/reviews/${reviewId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Review deleted')
|
||||
setReviews(prev => prev.filter(review => review.id !== reviewId))
|
||||
} else {
|
||||
toast.error('Failed to delete review')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete review')
|
||||
}
|
||||
}
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
return Array.from({ length: 5 }, (_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={`h-4 w-4 ${
|
||||
index < rating
|
||||
? 'text-yellow-400 fill-current'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Reviews Management</h1>
|
||||
<p className="text-gray-600">Moderate customer reviews and feedback</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>All Reviews</CardTitle>
|
||||
<div className="flex items-center space-x-2">
|
||||
<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 reviews..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 w-64"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as any)}
|
||||
className="rounded-md border border-gray-300 px-3 py-2"
|
||||
>
|
||||
<option value="all">All Reviews</option>
|
||||
<option value="pending">Pending Approval</option>
|
||||
<option value="approved">Approved</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="text-center py-8">Loading...</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Rating</TableHead>
|
||||
<TableHead>Review</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Stats</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reviews.map((review) => (
|
||||
<TableRow key={review.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{review.product.name}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{review.user.name}</p>
|
||||
<p className="text-sm text-gray-500">{review.user.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-1">
|
||||
{renderStars(review.rating)}
|
||||
<span className="ml-1 text-sm">({review.rating})</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-xs">
|
||||
{review.title && (
|
||||
<p className="font-medium text-sm truncate">{review.title}</p>
|
||||
)}
|
||||
{review.comment && (
|
||||
<p className="text-sm text-gray-600 truncate">{review.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Badge variant={review.isApproved ? 'default' : 'secondary'}>
|
||||
{review.isApproved ? 'Approved' : 'Pending'}
|
||||
</Badge>
|
||||
{review.isVerified && (
|
||||
<Badge variant="outline" className="text-green-600 border-green-600">
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>👍 {review.helpfulVotes}</div>
|
||||
{review.reportCount > 0 && (
|
||||
<div className="text-red-600">🚩 {review.reportCount}</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-gray-600">
|
||||
{formatDistanceToNow(new Date(review.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
{!review.isApproved && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleApprove(review.id)}
|
||||
className="text-green-600 border-green-600"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{review.isApproved && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleReject(review.id)}
|
||||
className="text-orange-600 border-orange-600"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDelete(review.id)}
|
||||
className="text-red-600 border-red-600"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
732
app/admin/settings/page.tsx
Normal file
732
app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,732 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } 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 { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import {
|
||||
Settings,
|
||||
DollarSign,
|
||||
Users,
|
||||
Package,
|
||||
Mail,
|
||||
Shield,
|
||||
Database,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Save,
|
||||
RefreshCw
|
||||
} from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface CommissionSetting {
|
||||
id: string
|
||||
level: number
|
||||
percentage: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
image: string | null
|
||||
isActive: boolean
|
||||
productCount: number
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
siteName: string
|
||||
siteDescription: string
|
||||
supportEmail: string
|
||||
minimumPayout: number
|
||||
enableReferrals: boolean
|
||||
enableCommissions: boolean
|
||||
maintenanceMode: boolean
|
||||
allowRegistration: boolean
|
||||
}
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const [commissionSettings, setCommissionSettings] = useState<CommissionSetting[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettings>({
|
||||
siteName: 'Padmaaja Rasooi',
|
||||
siteDescription: 'Premium quality rice products and grains. Experience the finest rice sourced directly from local farmers.',
|
||||
supportEmail: 'info@padmajarice.com',
|
||||
minimumPayout: 1000,
|
||||
enableReferrals: true,
|
||||
enableCommissions: true,
|
||||
maintenanceMode: false,
|
||||
allowRegistration: true
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [editingCommission, setEditingCommission] = useState<CommissionSetting | null>(null)
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||
const [newCommission, setNewCommission] = useState({ level: 1, percentage: 10, isActive: true })
|
||||
const [newCategory, setNewCategory] = useState({ name: '', description: '', image: '' })
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
}, [])
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [commissionsRes, categoriesRes, systemRes] = await Promise.all([
|
||||
fetch('/api/admin/settings/commissions'),
|
||||
fetch('/api/admin/settings/categories'),
|
||||
fetch('/api/admin/settings/system')
|
||||
])
|
||||
|
||||
const [commissionsData, categoriesData, systemData] = await Promise.all([
|
||||
commissionsRes.json(),
|
||||
categoriesRes.json(),
|
||||
systemRes.json()
|
||||
])
|
||||
|
||||
setCommissionSettings(commissionsData.settings || [])
|
||||
setCategories(categoriesData.categories || [])
|
||||
setSystemSettings(prev => ({ ...prev, ...systemData }))
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error)
|
||||
toast.error('Failed to load settings')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveSystemSettings = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
const response = await fetch('/api/admin/settings/system', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(systemSettings)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save settings')
|
||||
|
||||
toast.success('System settings saved successfully')
|
||||
|
||||
// Show warning if maintenance mode was enabled
|
||||
if (systemSettings.maintenanceMode) {
|
||||
toast.warning('⚠️ Maintenance mode enabled! Public site is now offline.')
|
||||
} else {
|
||||
toast.success('✅ Maintenance mode disabled. Public site is now online.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving system settings:', error)
|
||||
toast.error('Failed to save system settings')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveCommissionSetting = async (setting: CommissionSetting) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/settings/commissions/${setting.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(setting)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save commission setting')
|
||||
|
||||
toast.success('Commission setting saved successfully')
|
||||
fetchSettings()
|
||||
setEditingCommission(null)
|
||||
} catch (error) {
|
||||
console.error('Error saving commission setting:', error)
|
||||
toast.error('Failed to save commission setting')
|
||||
}
|
||||
}
|
||||
|
||||
const addCommissionSetting = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/settings/commissions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newCommission)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to add commission setting')
|
||||
|
||||
toast.success('Commission setting added successfully')
|
||||
setNewCommission({ level: 1, percentage: 10, isActive: true })
|
||||
fetchSettings()
|
||||
} catch (error) {
|
||||
console.error('Error adding commission setting:', error)
|
||||
toast.error('Failed to add commission setting')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCommissionSetting = async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/settings/commissions/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete commission setting')
|
||||
|
||||
toast.success('Commission setting deleted successfully')
|
||||
fetchSettings()
|
||||
} catch (error) {
|
||||
console.error('Error deleting commission setting:', error)
|
||||
toast.error('Failed to delete commission setting')
|
||||
}
|
||||
}
|
||||
|
||||
const saveCategory = async (category: Category) => {
|
||||
try {
|
||||
const url = category.id.startsWith('new')
|
||||
? '/api/admin/settings/categories'
|
||||
: `/api/admin/settings/categories/${category.id}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: category.id.startsWith('new') ? 'POST' : 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: category.name,
|
||||
description: category.description,
|
||||
image: category.image,
|
||||
isActive: category.isActive
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save category')
|
||||
|
||||
toast.success('Category saved successfully')
|
||||
fetchSettings()
|
||||
setEditingCategory(null)
|
||||
setNewCategory({ name: '', description: '', image: '' })
|
||||
} catch (error) {
|
||||
console.error('Error saving category:', error)
|
||||
toast.error('Failed to save category')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCategory = async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/settings/categories/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete category')
|
||||
|
||||
toast.success('Category deleted successfully')
|
||||
fetchSettings()
|
||||
} catch (error) {
|
||||
console.error('Error deleting category:', error)
|
||||
toast.error('Failed to delete category')
|
||||
}
|
||||
}
|
||||
|
||||
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 p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* Header with maintenance warning */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-transparent">
|
||||
System Settings
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">Manage your platform configuration and settings</p>
|
||||
</div>
|
||||
<Button onClick={() => fetchSettings()} variant="outline">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{systemSettings.maintenanceMode && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="h-5 w-5 text-red-600" />
|
||||
<h3 className="font-medium text-red-900">Maintenance Mode Active</h3>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-sm text-red-700">
|
||||
The public website is currently offline for regular users.
|
||||
</p>
|
||||
<p className="text-sm text-red-600 font-medium">
|
||||
✅ Admin users (like you) can still access all pages normally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<Tabs defaultValue="system" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="system" className="flex items-center space-x-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>System</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="commissions" className="flex items-center space-x-2">
|
||||
<DollarSign className="h-4 w-4" />
|
||||
<span>Commissions</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="categories" className="flex items-center space-x-2">
|
||||
<Package className="h-4 w-4" />
|
||||
<span>Categories</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security" className="flex items-center space-x-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>Security</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* System Settings */}
|
||||
<TabsContent value="system">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
<span>General Settings</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label htmlFor="siteName">Site Name</Label>
|
||||
<Input
|
||||
id="siteName"
|
||||
value={systemSettings.siteName}
|
||||
onChange={(e) => setSystemSettings(prev => ({ ...prev, siteName: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="supportEmail">Support Email</Label>
|
||||
<Input
|
||||
id="supportEmail"
|
||||
type="email"
|
||||
value={systemSettings.supportEmail}
|
||||
onChange={(e) => setSystemSettings(prev => ({ ...prev, supportEmail: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="siteDescription">Site Description</Label>
|
||||
<Textarea
|
||||
id="siteDescription"
|
||||
value={systemSettings.siteDescription}
|
||||
onChange={(e) => setSystemSettings(prev => ({ ...prev, siteDescription: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="minimumPayout">Minimum Payout Amount (₹)</Label>
|
||||
<Input
|
||||
id="minimumPayout"
|
||||
type="number"
|
||||
value={systemSettings.minimumPayout}
|
||||
onChange={(e) => setSystemSettings(prev => ({ ...prev, minimumPayout: parseFloat(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Enable Referral System</Label>
|
||||
<p className="text-sm text-gray-500">Allow users to refer others</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={systemSettings.enableReferrals}
|
||||
onCheckedChange={(checked) => setSystemSettings(prev => ({ ...prev, enableReferrals: checked }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Enable Commissions</Label>
|
||||
<p className="text-sm text-gray-500">Enable commission calculations</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={systemSettings.enableCommissions}
|
||||
onCheckedChange={(checked) => setSystemSettings(prev => ({ ...prev, enableCommissions: checked }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Allow Registration</Label>
|
||||
<p className="text-sm text-gray-500">Allow new user registrations</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={systemSettings.allowRegistration}
|
||||
onCheckedChange={(checked) => setSystemSettings(prev => ({ ...prev, allowRegistration: checked }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg bg-red-50 border-red-200">
|
||||
<div>
|
||||
<Label className="text-red-900 font-medium">Maintenance Mode</Label>
|
||||
<p className="text-sm text-red-700 mt-1">Put site in maintenance mode</p>
|
||||
<p className="text-xs text-red-600 font-medium">
|
||||
⚠️ This will take the public site offline for regular users. Admin users will still have full access.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={systemSettings.maintenanceMode}
|
||||
onCheckedChange={(checked) => setSystemSettings(prev => ({ ...prev, maintenanceMode: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={saveSystemSettings} disabled={saving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Commission Settings */}
|
||||
<TabsContent value="commissions">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<DollarSign className="h-5 w-5" />
|
||||
<span>Commission Structure</span>
|
||||
</CardTitle>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Level
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Commission Level</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Level</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={newCommission.level}
|
||||
onChange={(e) => setNewCommission(prev => ({ ...prev, level: parseInt(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Percentage (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={newCommission.percentage}
|
||||
onChange={(e) => setNewCommission(prev => ({ ...prev, percentage: parseFloat(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={newCommission.isActive}
|
||||
onCheckedChange={(checked) => setNewCommission(prev => ({ ...prev, isActive: checked }))}
|
||||
/>
|
||||
<Label>Active</Label>
|
||||
</div>
|
||||
<Button onClick={addCommissionSetting} className="w-full">
|
||||
Add Commission Level
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{commissionSettings.map((setting) => (
|
||||
<div key={setting.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">Level {setting.level}</p>
|
||||
<p className="text-sm text-gray-500">{setting.percentage}% commission</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant={setting.isActive ? 'default' : 'secondary'}>
|
||||
{setting.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingCommission(setting)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => deleteCommissionSetting(setting.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Categories */}
|
||||
<TabsContent value="categories">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Package className="h-5 w-5" />
|
||||
<span>Product Categories</span>
|
||||
</CardTitle>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Category
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Category</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={newCategory.name}
|
||||
onChange={(e) => setNewCategory(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={newCategory.description}
|
||||
onChange={(e) => setNewCategory(prev => ({ ...prev, description: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Image URL</Label>
|
||||
<Input
|
||||
value={newCategory.image}
|
||||
onChange={(e) => setNewCategory(prev => ({ ...prev, image: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => saveCategory({
|
||||
id: 'new-category',
|
||||
name: newCategory.name,
|
||||
description: newCategory.description,
|
||||
image: newCategory.image,
|
||||
isActive: true,
|
||||
productCount: 0
|
||||
})}
|
||||
className="w-full"
|
||||
>
|
||||
Add Category
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-medium">{category.name}</h3>
|
||||
<Badge variant={category.isActive ? 'default' : 'secondary'}>
|
||||
{category.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-2">{category.description}</p>
|
||||
<p className="text-xs text-gray-400 mb-3">{category.productCount} products</p>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingCategory(category)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => deleteCategory(category.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Security */}
|
||||
<TabsContent value="security">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
<span>Security Settings</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h3 className="font-medium text-blue-900 mb-2">Database Status</h3>
|
||||
<p className="text-sm text-blue-700">Database connection is healthy</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<h3 className="font-medium text-yellow-900 mb-2">Environment Variables</h3>
|
||||
<p className="text-sm text-yellow-700">All required environment variables are configured</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<h3 className="font-medium text-green-900 mb-2">SSL Certificate</h3>
|
||||
<p className="text-sm text-green-700">SSL certificate is valid and up to date</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Edit Dialogs */}
|
||||
{editingCommission && (
|
||||
<Dialog open={!!editingCommission} onOpenChange={() => setEditingCommission(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Commission Setting</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Level</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editingCommission.level}
|
||||
onChange={(e) => setEditingCommission(prev => prev ? { ...prev, level: parseInt(e.target.value) } : null)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Percentage (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editingCommission.percentage}
|
||||
onChange={(e) => setEditingCommission(prev => prev ? { ...prev, percentage: parseFloat(e.target.value) } : null)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={editingCommission.isActive}
|
||||
onCheckedChange={(checked) => setEditingCommission(prev => prev ? { ...prev, isActive: checked } : null)}
|
||||
/>
|
||||
<Label>Active</Label>
|
||||
</div>
|
||||
<Button onClick={() => saveCommissionSetting(editingCommission)} className="w-full">
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{editingCategory && (
|
||||
<Dialog open={!!editingCategory} onOpenChange={() => setEditingCategory(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Category</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={editingCategory.name}
|
||||
onChange={(e) => setEditingCategory(prev => prev ? { ...prev, name: e.target.value } : null)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={editingCategory.description || ''}
|
||||
onChange={(e) => setEditingCategory(prev => prev ? { ...prev, description: e.target.value } : null)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Image URL</Label>
|
||||
<Input
|
||||
value={editingCategory.image || ''}
|
||||
onChange={(e) => setEditingCategory(prev => prev ? { ...prev, image: e.target.value } : null)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={editingCategory.isActive}
|
||||
onCheckedChange={(checked) => setEditingCategory(prev => prev ? { ...prev, isActive: checked } : null)}
|
||||
/>
|
||||
<Label>Active</Label>
|
||||
</div>
|
||||
<Button onClick={() => saveCategory(editingCategory)} className="w-full">
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
190
app/admin/users/page.tsx
Normal file
190
app/admin/users/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
'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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Search, Filter, Download, UserPlus } from 'lucide-react'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: 'ADMIN' | 'MEMBER' | 'CUSTOMER' | 'WHOLESALER' | 'PART_TIME'
|
||||
isActive: boolean
|
||||
joinedAt: string
|
||||
referralCode: string
|
||||
_count: {
|
||||
referrals: number
|
||||
orders: number
|
||||
}
|
||||
referrer?: {
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [roleFilter, setRoleFilter] = useState('all')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: '10'
|
||||
})
|
||||
|
||||
if (search) params.append('search', search)
|
||||
if (roleFilter && roleFilter !== 'all') params.append('role', roleFilter)
|
||||
|
||||
const response = await fetch(`/api/admin/users?${params}`)
|
||||
const data = await response.json()
|
||||
setUsers(data.users || [])
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [search, roleFilter, page])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [fetchUsers])
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'ADMIN': return 'bg-red-500'
|
||||
case 'MEMBER': return 'bg-blue-500'
|
||||
case 'CUSTOMER': return 'bg-gray-500'
|
||||
default: return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Users</h1>
|
||||
<p className="text-gray-600">Manage your platform users</p>
|
||||
</div>
|
||||
<Button>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>All Users</CardTitle>
|
||||
<div className="flex items-center space-x-2">
|
||||
<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={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 w-64"
|
||||
/>
|
||||
</div>
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="Role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Roles</SelectItem>
|
||||
<SelectItem value="ADMIN">Admin</SelectItem>
|
||||
<SelectItem value="MEMBER">Member</SelectItem>
|
||||
<SelectItem value="CUSTOMER">Customer</SelectItem>
|
||||
<SelectItem value="WHOLESALER">Wholesaler</SelectItem>
|
||||
<SelectItem value="PART_TIME">Part Time</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="text-center py-8">Loading...</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Referrals</TableHead>
|
||||
<TableHead>Orders</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{user.name}</p>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
{user.referrer && (
|
||||
<p className="text-xs text-gray-400">
|
||||
Referred by: {user.referrer.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${getRoleBadgeColor(user.role)} text-white`}>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.isActive ? 'default' : 'secondary'}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{user._count.referrals}</TableCell>
|
||||
<TableCell>{user._count.orders}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(user.joinedAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm">
|
||||
Edit
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user