first commit
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user