Spaces:
Running
Running
import { useState, useEffect } from "react"; | |
import { useLocation } from "wouter"; | |
import { useAuth } from "@/hooks/use-auth"; | |
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; | |
import { adminApi, categoriesApi, productsApi } from "@/lib/api"; | |
import { useForm } from "react-hook-form"; | |
import { zodResolver } from "@hookform/resolvers/zod"; | |
import { z } from "zod"; | |
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | |
import { Button } from "@/components/ui/button"; | |
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; | |
import { Input } from "@/components/ui/input"; | |
import { Badge } from "@/components/ui/badge"; | |
import { Separator } from "@/components/ui/separator"; | |
import { Skeleton } from "@/components/ui/skeleton"; | |
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; | |
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; | |
import { useToast } from "@/hooks/use-toast"; | |
import { Users, Store, Package, Settings, Plus, Trash2, Edit, Eye, ShoppingCart, MapPin, ExternalLink } from "lucide-react"; | |
import { useIsMobile } from "@/hooks/use-mobile"; | |
const sellerSchema = z.object({ | |
username: z.string().min(3, "Username must be at least 3 characters"), | |
password: z.string().min(6, "Password must be at least 6 characters"), | |
}); | |
const categorySchema = z.object({ | |
name: z.string().min(1, "Category name is required"), | |
icon: z.string().optional(), | |
imageUrl: z.string().url().optional().or(z.literal("")), | |
}); | |
const productSchema = z.object({ | |
title: z.string().min(1, "Product title is required"), | |
description: z.string().min(1, "Product description is required"), | |
price: z.string().min(1, "Price is required"), | |
originalPrice: z.string().optional(), | |
stock: z.string().min(1, "Stock is required"), | |
categoryId: z.string().min(1, "Category is required"), | |
isActive: z.boolean().default(true), | |
}); | |
type SellerFormData = z.infer<typeof sellerSchema>; | |
type CategoryFormData = z.infer<typeof categorySchema>; | |
type ProductFormData = z.infer<typeof productSchema>; | |
export default function AdminDev() { | |
const [, setLocation] = useLocation(); | |
const { userType, adminLogin } = useAuth(); | |
const { toast } = useToast(); | |
const queryClient = useQueryClient(); | |
const [isSellerDialogOpen, setIsSellerDialogOpen] = useState(false); | |
const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false); | |
const [editingCategory, setEditingCategory] = useState<any>(null); | |
const [editingProduct, setEditingProduct] = useState<any>(null); | |
const [isProductDialogOpen, setIsProductDialogOpen] = useState(false); | |
const [activeTab, setActiveTab] = useState("sellers"); | |
const isMobile = useIsMobile(); | |
// Auto-login as admin if not already | |
useEffect(() => { | |
if (userType !== 'admin') { | |
adminLogin(); | |
} | |
}, [userType, adminLogin]); | |
const { data: sellers = [], isLoading: sellersLoading } = useQuery({ | |
queryKey: ['/api/admin/sellers'], | |
queryFn: () => adminApi.getSellers(), | |
}); | |
const { data: categories = [], isLoading: categoriesLoading } = useQuery({ | |
queryKey: ['/api/categories'], | |
queryFn: () => categoriesApi.getAll(), | |
}); | |
const { data: products = [], isLoading: productsLoading } = useQuery({ | |
queryKey: ['/api/products'], | |
queryFn: () => productsApi.getAll(), | |
}); | |
const { data: orders = [], isLoading: ordersLoading } = useQuery({ | |
queryKey: ['/api/admin/orders'], | |
queryFn: () => adminApi.getOrders(), | |
}); | |
const { data: users = [], isLoading: usersLoading } = useQuery({ | |
queryKey: ['/api/admin/users'], | |
queryFn: async () => { | |
const response = await fetch('/api/admin/users'); | |
return response.json(); | |
}, | |
}); | |
const sellerForm = useForm<SellerFormData>({ | |
resolver: zodResolver(sellerSchema), | |
defaultValues: { | |
username: "", | |
password: "", | |
}, | |
}); | |
const categoryForm = useForm<CategoryFormData>({ | |
resolver: zodResolver(categorySchema), | |
defaultValues: { | |
name: "", | |
icon: "", | |
imageUrl: "", | |
}, | |
}); | |
const productForm = useForm<ProductFormData>({ | |
resolver: zodResolver(productSchema), | |
defaultValues: { | |
title: "", | |
description: "", | |
price: "", | |
originalPrice: "", | |
stock: "", | |
categoryId: "", | |
isActive: true, | |
}, | |
}); | |
const createSellerMutation = useMutation({ | |
mutationFn: (data: SellerFormData) => adminApi.createSeller(data), | |
onSuccess: () => { | |
queryClient.invalidateQueries({ queryKey: ['/api/admin/sellers'] }); | |
setIsSellerDialogOpen(false); | |
sellerForm.reset(); | |
toast({ | |
title: "Seller created", | |
description: "New seller account has been created successfully.", | |
}); | |
}, | |
onError: () => { | |
toast({ | |
variant: "destructive", | |
title: "Error", | |
description: "Failed to create seller account.", | |
}); | |
}, | |
}); | |
const deleteSellerMutation = useMutation({ | |
mutationFn: (id: string) => adminApi.deleteSeller(id), | |
onSuccess: () => { | |
queryClient.invalidateQueries({ queryKey: ['/api/admin/sellers'] }); | |
toast({ | |
title: "Seller deleted", | |
description: "Seller account and all associated products have been deleted.", | |
}); | |
}, | |
onError: () => { | |
toast({ | |
variant: "destructive", | |
title: "Error", | |
description: "Failed to delete seller account.", | |
}); | |
}, | |
}); | |
const createCategoryMutation = useMutation({ | |
mutationFn: (data: CategoryFormData) => categoriesApi.create(data), | |
onSuccess: () => { | |
queryClient.invalidateQueries({ queryKey: ['/api/categories'] }); | |
setIsCategoryDialogOpen(false); | |
categoryForm.reset(); | |
toast({ | |
title: "Category created", | |
description: "New category has been created successfully.", | |
}); | |
}, | |
onError: () => { | |
toast({ | |
variant: "destructive", | |
title: "Error", | |
description: "Failed to create category.", | |
}); | |
}, | |
}); | |
const deleteCategoryMutation = useMutation({ | |
mutationFn: (id: string) => categoriesApi.delete(id), | |
onSuccess: () => { | |
queryClient.invalidateQueries({ queryKey: ['/api/categories'] }); | |
toast({ | |
title: "Category deleted", | |
description: "Category has been deleted successfully.", | |
}); | |
}, | |
onError: () => { | |
toast({ | |
variant: "destructive", | |
title: "Error", | |
description: "Failed to delete category.", | |
}); | |
}, | |
}); | |
const updateCategoryMutation = useMutation({ | |
mutationFn: ({ id, data }: { id: string; data: CategoryFormData }) => | |
categoriesApi.update(id, data), | |
onSuccess: () => { | |
queryClient.invalidateQueries({ queryKey: ['/api/categories'] }); | |
setEditingCategory(null); | |
setIsCategoryDialogOpen(false); | |
categoryForm.reset(); | |
toast({ | |
title: "Category updated", | |
description: "Category has been updated successfully.", | |
}); | |
}, | |
onError: () => { | |
toast({ | |
variant: "destructive", | |
title: "Error", | |
description: "Failed to update category.", | |
}); | |
}, | |
}); | |
const updateProductMutation = useMutation({ | |
mutationFn: ({ id, data }: { id: string; data: ProductFormData }) => | |
adminApi.updateProduct(id, data), | |
onSuccess: () => { | |
queryClient.invalidateQueries({ queryKey: ['/api/products'] }); | |
setEditingProduct(null); | |
setIsProductDialogOpen(false); | |
productForm.reset(); | |
toast({ | |
title: "Product updated", | |
description: "Product has been updated successfully.", | |
}); | |
}, | |
onError: () => { | |
toast({ | |
variant: "destructive", | |
title: "Error", | |
description: "Failed to update product.", | |
}); | |
}, | |
}); | |
const deleteProductMutation = useMutation({ | |
mutationFn: (id: string) => adminApi.deleteProduct(id), | |
onSuccess: () => { | |
queryClient.invalidateQueries({ queryKey: ['/api/products'] }); | |
toast({ | |
title: "Product deleted", | |
description: "Product has been deleted successfully.", | |
}); | |
}, | |
onError: () => { | |
toast({ | |
variant: "destructive", | |
title: "Error", | |
description: "Failed to delete product.", | |
}); | |
}, | |
}); | |
const updateOrderMutation = useMutation({ | |
mutationFn: ({ id, data }: { id: string; data: any }) => adminApi.updateOrder(id, data), | |
onSuccess: () => { | |
queryClient.invalidateQueries({ queryKey: ['/api/admin/orders'] }); | |
toast({ | |
title: "Order updated", | |
description: "Order status has been updated successfully.", | |
}); | |
}, | |
onError: () => { | |
toast({ | |
variant: "destructive", | |
title: "Error", | |
description: "Failed to update order status.", | |
}); | |
}, | |
}); | |
const handleUpdateOrderStatus = (id: string, data: any) => { | |
updateOrderMutation.mutate({ id, data }); | |
}; | |
const onCreateSeller = (data: SellerFormData) => { | |
createSellerMutation.mutate(data); | |
}; | |
const onCreateCategory = (data: CategoryFormData) => { | |
createCategoryMutation.mutate(data); | |
}; | |
const onEditCategory = (data: CategoryFormData) => { | |
if (editingCategory) { | |
updateCategoryMutation.mutate({ id: editingCategory.id, data }); | |
} | |
}; | |
const onEditProduct = (data: ProductFormData) => { | |
if (editingProduct) { | |
updateProductMutation.mutate({ id: editingProduct.id, data }); | |
} | |
}; | |
const handleEditCategory = (category: any) => { | |
setEditingCategory(category); | |
categoryForm.reset({ | |
name: category.name, | |
icon: category.icon || "", | |
imageUrl: category.imageUrl || "", | |
}); | |
setIsCategoryDialogOpen(true); | |
}; | |
const handleEditProduct = (product: any) => { | |
setEditingProduct(product); | |
productForm.reset({ | |
title: product.title, | |
description: product.description, | |
price: product.price.toString(), | |
originalPrice: product.originalPrice ? product.originalPrice.toString() : "", | |
stock: product.stock.toString(), | |
categoryId: product.categoryId, | |
isActive: product.isActive, | |
}); | |
setIsProductDialogOpen(true); | |
}; | |
const handleCancelEdit = () => { | |
setEditingCategory(null); | |
setEditingProduct(null); | |
setIsCategoryDialogOpen(false); | |
setIsProductDialogOpen(false); | |
categoryForm.reset(); | |
productForm.reset(); | |
}; | |
return ( | |
<div className="min-h-screen page-gradient pb-24 md:pb-0"> | |
<header className="glass-strong border-0 shadow-2xl sticky top-0 z-40"> | |
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> | |
<div className="flex justify-between items-center h-16"> | |
<div> | |
<h1 className="text-2xl font-bold text-primary" data-testid="admin-title"> | |
Shoposphere Admin | |
</h1> | |
<p className="text-sm text-muted-foreground">Developer Administration Panel</p> | |
</div> | |
<Button onClick={() => setLocation('/')} variant="outline" data-testid="back-to-site"> | |
Back to Site | |
</Button> | |
</div> | |
</div> | |
</header> | |
<div className="max-w-7xl mx-auto px-3 sm:px-4 md:px-6 lg:px-8 py-4 md:py-6 lg:py-8" data-testid="admin-dev-page"> | |
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4 md:space-y-6"> | |
<TabsContent value="sellers"> | |
<Card className="glass-card border-0"> | |
<CardHeader className="flex flex-row items-center justify-between"> | |
<CardTitle>Seller Management</CardTitle> | |
<Dialog open={isSellerDialogOpen} onOpenChange={setIsSellerDialogOpen}> | |
<DialogTrigger asChild> | |
<Button data-testid="create-seller-button"> | |
<Plus className="mr-2 h-4 w-4" /> | |
Create Seller | |
</Button> | |
</DialogTrigger> | |
<DialogContent className="bg-white border border-black"> | |
<DialogHeader> | |
<DialogTitle>Create New Seller</DialogTitle> | |
</DialogHeader> | |
<Form {...sellerForm}> | |
<form onSubmit={sellerForm.handleSubmit(onCreateSeller)} className="space-y-4"> | |
<FormField | |
control={sellerForm.control} | |
name="username" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Username</FormLabel> | |
<FormControl> | |
<Input {...field} data-testid="seller-username-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={sellerForm.control} | |
name="password" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Password</FormLabel> | |
<FormControl> | |
<Input {...field} type="password" data-testid="seller-password-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<Button type="submit" className="w-full" data-testid="submit-seller"> | |
Create Seller | |
</Button> | |
</form> | |
</Form> | |
</DialogContent> | |
</Dialog> | |
</CardHeader> | |
<CardContent> | |
{sellersLoading ? ( | |
<div className="space-y-4"> | |
{[...Array(3)].map((_, i) => ( | |
<div key={i} className="flex items-center justify-between p-4 glass border-0 rounded-2xl"> | |
<Skeleton className="h-6 w-48" /> | |
<Skeleton className="h-8 w-20" /> | |
</div> | |
))} | |
</div> | |
) : sellers.length > 0 ? ( | |
<div className="space-y-4" data-testid="sellers-list"> | |
{sellers.map((seller: any) => ( | |
<div key={seller.id} className="flex items-center justify-between p-4 glass border-0 rounded-2xl hover:scale-105 transition-all duration-300" data-testid={`seller-${seller.id}`}> | |
<div> | |
<h3 className="font-medium text-foreground" data-testid={`seller-username-${seller.id}`}> | |
{seller.username} | |
</h3> | |
<p className="text-sm text-muted-foreground" data-testid={`seller-created-${seller.id}`}> | |
Created: {new Date(seller.createdAt).toLocaleDateString()} | |
</p> | |
<div className="mt-2 p-2 bg-yellow-50 dark:bg-yellow-900/20 rounded border"> | |
<p className="text-xs font-medium text-yellow-800 dark:text-yellow-200 mb-1">Login Credentials:</p> | |
<p className="text-xs text-yellow-700 dark:text-yellow-300" data-testid={`seller-credentials-${seller.id}`}> | |
<span className="font-medium">Username:</span> {seller.username}<br/> | |
<span className="font-medium">Password:</span> {seller.plainTextPassword || 'Not available'} | |
</p> | |
</div> | |
</div> | |
<div className="flex items-center space-x-2"> | |
<Button | |
variant="outline" | |
size="sm" | |
onClick={() => setLocation(`/admin-prospective=seller`)} | |
data-testid={`view-seller-${seller.id}`} | |
> | |
<Eye className="h-4 w-4" /> | |
</Button> | |
<Button | |
variant="destructive" | |
size="sm" | |
onClick={() => deleteSellerMutation.mutate(seller.id)} | |
data-testid={`delete-seller-${seller.id}`} | |
> | |
<Trash2 className="h-4 w-4" /> | |
</Button> | |
</div> | |
</div> | |
))} | |
</div> | |
) : ( | |
<div className="text-center py-12" data-testid="no-sellers"> | |
<Users className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> | |
<p className="text-muted-foreground">No sellers created yet.</p> | |
</div> | |
)} | |
</CardContent> | |
</Card> | |
</TabsContent> | |
<TabsContent value="users"> | |
<Card className="glass-card border-0"> | |
<CardHeader> | |
<CardTitle className="flex items-center gap-2"> | |
<MapPin className="h-5 w-5" /> | |
User Locations & Information | |
</CardTitle> | |
</CardHeader> | |
<CardContent> | |
{usersLoading ? ( | |
<div className="space-y-4"> | |
{[...Array(5)].map((_, i) => ( | |
<div key={i} className="flex items-center justify-between p-4 glass border-0 rounded-2xl"> | |
<Skeleton className="h-6 w-48" /> | |
<Skeleton className="h-8 w-20" /> | |
</div> | |
))} | |
</div> | |
) : users.length > 0 ? ( | |
<div className="space-y-4" data-testid="users-list"> | |
{users.map((user: any) => ( | |
<div key={user.id} className="flex items-start justify-between p-4 glass border-0 rounded-2xl hover:scale-[1.02] transition-all duration-300" data-testid={`user-${user.id}`}> | |
<div className="flex-1"> | |
<div className="flex items-center gap-3 mb-2"> | |
<h3 className="font-medium text-foreground" data-testid={`user-name-${user.id}`}> | |
{user.firstName} {user.lastName} | |
</h3> | |
<Badge variant="secondary" className="text-xs"> | |
@{user.username} | |
</Badge> | |
</div> | |
<div className="space-y-1 text-sm text-muted-foreground mb-3"> | |
<p data-testid={`user-contact-${user.id}`}> | |
📧 {user.email} • 📞 {user.phone} | |
</p> | |
<p className="text-xs"> | |
Joined: {new Date(user.createdAt).toLocaleDateString()} | |
</p> | |
</div> | |
{/* Location Information */} | |
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg"> | |
{user.locationDetectedAutomatically && user.googleMapsUrl ? ( | |
<div className="space-y-2"> | |
<div className="flex items-center gap-2"> | |
<MapPin className="h-4 w-4 text-green-600" /> | |
<span className="text-sm font-medium text-green-600" data-testid={`user-location-auto-${user.id}`}> | |
Auto-detected location | |
</span> | |
</div> | |
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400"> | |
<span data-testid={`coordinates-${user.id}`}> | |
📍 {Number(user.latitude).toFixed(4)}, {Number(user.longitude).toFixed(4)} | |
</span> | |
</div> | |
<Button | |
size="sm" | |
variant="outline" | |
className="h-7 px-2 text-xs" | |
onClick={() => window.open(user.googleMapsUrl, '_blank')} | |
data-testid={`view-location-${user.id}`} | |
> | |
<ExternalLink className="mr-1 h-3 w-3" /> | |
View on Google Maps | |
</Button> | |
</div> | |
) : user.street && user.city ? ( | |
<div className="space-y-2"> | |
<div className="flex items-center gap-2"> | |
<MapPin className="h-4 w-4 text-blue-600" /> | |
<span className="text-sm font-medium text-blue-600"> | |
Manual address | |
</span> | |
</div> | |
<p className="text-sm text-gray-600 dark:text-gray-400" data-testid={`user-location-manual-${user.id}`}> | |
{user.street}, {user.city}, {user.state} {user.pinCode}, {user.country} | |
</p> | |
</div> | |
) : ( | |
<div className="flex items-center gap-2"> | |
<MapPin className="h-4 w-4 text-gray-400" /> | |
<span className="text-sm text-gray-400" data-testid={`user-location-none-${user.id}`}> | |
No location provided | |
</span> | |
</div> | |
)} | |
</div> | |
</div> | |
<div className="text-right text-xs text-muted-foreground"> | |
<p>User ID: {user.id.slice(-8)}</p> | |
</div> | |
</div> | |
))} | |
</div> | |
) : ( | |
<div className="text-center py-12" data-testid="no-users"> | |
<Users className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> | |
<p className="text-muted-foreground">No users registered yet.</p> | |
</div> | |
)} | |
</CardContent> | |
</Card> | |
</TabsContent> | |
<TabsContent value="categories"> | |
<Card className="glass-card border-0"> | |
<CardHeader className="flex flex-row items-center justify-between"> | |
<CardTitle>Category Management</CardTitle> | |
<Dialog open={isCategoryDialogOpen} onOpenChange={(open) => { | |
setIsCategoryDialogOpen(open); | |
if (!open) handleCancelEdit(); | |
}}> | |
<DialogTrigger asChild> | |
<Button data-testid="create-category-button"> | |
<Plus className="mr-2 h-4 w-4" /> | |
Create Category | |
</Button> | |
</DialogTrigger> | |
<DialogContent className="bg-white border border-black"> | |
<DialogHeader> | |
<DialogTitle>{editingCategory ? 'Edit Category' : 'Create New Category'}</DialogTitle> | |
</DialogHeader> | |
<Form {...categoryForm}> | |
<form onSubmit={categoryForm.handleSubmit(editingCategory ? onEditCategory : onCreateCategory)} className="space-y-4"> | |
<FormField | |
control={categoryForm.control} | |
name="name" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Category Name</FormLabel> | |
<FormControl> | |
<Input {...field} data-testid="category-name-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={categoryForm.control} | |
name="icon" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Icon (optional)</FormLabel> | |
<FormControl> | |
<Input {...field} placeholder="fa-laptop" data-testid="category-icon-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={categoryForm.control} | |
name="imageUrl" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Image URL (optional)</FormLabel> | |
<FormControl> | |
<Input {...field} placeholder="https://example.com/image.jpg" data-testid="category-image-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<div className="flex space-x-2"> | |
<Button | |
type="submit" | |
className="flex-1" | |
data-testid="submit-category" | |
disabled={updateCategoryMutation.isPending || createCategoryMutation.isPending} | |
> | |
{editingCategory ? 'Update Category' : 'Create Category'} | |
</Button> | |
{editingCategory && ( | |
<Button | |
type="button" | |
variant="outline" | |
onClick={handleCancelEdit} | |
data-testid="cancel-edit-category" | |
> | |
Cancel | |
</Button> | |
)} | |
</div> | |
</form> | |
</Form> | |
</DialogContent> | |
</Dialog> | |
</CardHeader> | |
<CardContent> | |
{categoriesLoading ? ( | |
<div className="space-y-4"> | |
{[...Array(3)].map((_, i) => ( | |
<div key={i} className="flex items-center justify-between p-4 glass border-0 rounded-2xl"> | |
<Skeleton className="h-6 w-48" /> | |
<Skeleton className="h-8 w-20" /> | |
</div> | |
))} | |
</div> | |
) : categories.length > 0 ? ( | |
<div className="space-y-4" data-testid="categories-list"> | |
{categories.map((category: any) => ( | |
<div key={category.id} className="flex items-center justify-between p-4 glass border-0 rounded-2xl hover:scale-105 transition-all duration-300" data-testid={`category-${category.id}`}> | |
<div className="flex items-center space-x-3"> | |
{category.imageUrl && ( | |
<div className="w-10 h-10 rounded-full overflow-hidden bg-gray-100 flex-shrink-0"> | |
<img | |
src={category.imageUrl} | |
alt={category.name} | |
className="w-full h-full object-cover" | |
/> | |
</div> | |
)} | |
<div> | |
<h3 className="font-medium text-foreground" data-testid={`category-name-${category.id}`}> | |
{category.name} | |
</h3> | |
{category.icon && ( | |
<p className="text-sm text-muted-foreground" data-testid={`category-icon-${category.id}`}> | |
Icon: {category.icon} | |
</p> | |
)} | |
{category.imageUrl && ( | |
<p className="text-xs text-muted-foreground truncate max-w-48" data-testid={`category-image-${category.id}`}> | |
Image: {category.imageUrl} | |
</p> | |
)} | |
</div> | |
</div> | |
<div className="flex items-center space-x-2"> | |
<Button | |
variant="outline" | |
size="sm" | |
onClick={() => handleEditCategory(category)} | |
data-testid={`edit-category-${category.id}`} | |
> | |
<Edit className="h-4 w-4" /> | |
</Button> | |
<Button | |
variant="destructive" | |
size="sm" | |
onClick={() => deleteCategoryMutation.mutate(category.id)} | |
data-testid={`delete-category-${category.id}`} | |
> | |
<Trash2 className="h-4 w-4" /> | |
</Button> | |
</div> | |
</div> | |
))} | |
</div> | |
) : ( | |
<div className="text-center py-12" data-testid="no-categories"> | |
<Store className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> | |
<p className="text-muted-foreground">No categories created yet.</p> | |
</div> | |
)} | |
</CardContent> | |
</Card> | |
</TabsContent> | |
<TabsContent value="products"> | |
<Card className="glass-card border-0"> | |
<CardHeader className="flex flex-row items-center justify-between"> | |
<CardTitle>Product Management</CardTitle> | |
<Dialog open={isProductDialogOpen} onOpenChange={(open) => { | |
setIsProductDialogOpen(open); | |
if (!open) handleCancelEdit(); | |
}}> | |
<DialogTrigger asChild> | |
<Button variant="outline" data-testid="edit-products-button"> | |
<Edit className="mr-2 h-4 w-4" /> | |
Edit Mode | |
</Button> | |
</DialogTrigger> | |
<DialogContent className="bg-white border border-black max-w-2xl mx-4 sm:mx-auto"> | |
<DialogHeader> | |
<DialogTitle>Edit Product</DialogTitle> | |
</DialogHeader> | |
{editingProduct && ( | |
<Form {...productForm}> | |
<form onSubmit={productForm.handleSubmit(onEditProduct)} className="space-y-4"> | |
<div className="grid grid-cols-2 gap-4"> | |
<FormField | |
control={productForm.control} | |
name="title" | |
render={({ field }) => ( | |
<FormItem className="col-span-2"> | |
<FormLabel>Product Title</FormLabel> | |
<FormControl> | |
<Input {...field} data-testid="product-title-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={productForm.control} | |
name="description" | |
render={({ field }) => ( | |
<FormItem className="col-span-2"> | |
<FormLabel>Description</FormLabel> | |
<FormControl> | |
<Input {...field} data-testid="product-description-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={productForm.control} | |
name="price" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Price</FormLabel> | |
<FormControl> | |
<Input {...field} type="number" step="0.01" data-testid="product-price-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={productForm.control} | |
name="originalPrice" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Original Price (optional)</FormLabel> | |
<FormControl> | |
<Input {...field} type="number" step="0.01" data-testid="product-original-price-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={productForm.control} | |
name="stock" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Stock</FormLabel> | |
<FormControl> | |
<Input {...field} type="number" data-testid="product-stock-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={productForm.control} | |
name="categoryId" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Category</FormLabel> | |
<FormControl> | |
<select {...field} className="w-full p-2 border rounded" data-testid="product-category-select"> | |
<option value="">Select a category</option> | |
{categories.map((category: any) => ( | |
<option key={category.id} value={category.id}> | |
{category.name} | |
</option> | |
))} | |
</select> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
</div> | |
<div className="flex space-x-2"> | |
<Button | |
type="submit" | |
className="flex-1" | |
data-testid="submit-product" | |
disabled={updateProductMutation.isPending} | |
> | |
Update Product | |
</Button> | |
<Button | |
type="button" | |
variant="outline" | |
onClick={handleCancelEdit} | |
data-testid="cancel-edit-product" | |
> | |
Cancel | |
</Button> | |
</div> | |
</form> | |
</Form> | |
)} | |
</DialogContent> | |
</Dialog> | |
</CardHeader> | |
<CardContent> | |
{productsLoading ? ( | |
<div className="space-y-4"> | |
{[...Array(5)].map((_, i) => ( | |
<div key={i} className="flex items-center justify-between p-4 border rounded-lg"> | |
<div className="flex items-center space-x-4"> | |
<Skeleton className="w-16 h-16" /> | |
<div> | |
<Skeleton className="h-6 w-48 mb-2" /> | |
<Skeleton className="h-4 w-32" /> | |
</div> | |
</div> | |
<Skeleton className="h-8 w-20" /> | |
</div> | |
))} | |
</div> | |
) : products.length > 0 ? ( | |
<div className="space-y-4" data-testid="products-list"> | |
{products.map((product: any) => ( | |
<div key={product.id} className="flex items-center justify-between p-4 glass border-0 rounded-2xl hover:scale-105 transition-all duration-300" data-testid={`product-${product.id}`}> | |
<div className="flex items-center space-x-4"> | |
<div className="w-16 h-16 bg-gray-100 rounded overflow-hidden"> | |
{product.images && product.images.length > 0 ? ( | |
<img | |
src={product.images[0]} | |
alt={product.title} | |
className="w-full h-full object-cover" | |
/> | |
) : ( | |
<div className="w-full h-full bg-gray-200 flex items-center justify-center"> | |
<span className="text-xs text-gray-400">No Image</span> | |
</div> | |
)} | |
</div> | |
<div> | |
<h3 className="font-medium text-gray-900" data-testid={`product-title-${product.id}`}> | |
{product.title} | |
</h3> | |
<p className="text-sm text-gray-500" data-testid={`product-seller-${product.id}`}> | |
By: {product.seller?.username || 'Unknown Seller'} | |
</p> | |
<div className="flex items-center space-x-2 mt-1"> | |
<span className="text-sm font-semibold text-gray-900" data-testid={`product-price-${product.id}`}> | |
₹{parseFloat(product.price).toFixed(2)} | |
</span> | |
<Badge variant={product.isActive ? 'default' : 'secondary'} data-testid={`product-status-${product.id}`}> | |
{product.isActive ? 'Active' : 'Inactive'} | |
</Badge> | |
</div> | |
</div> | |
</div> | |
<div className="flex items-center space-x-2"> | |
<Button | |
variant="outline" | |
size="sm" | |
onClick={() => setLocation(`/product/${product.id}`)} | |
data-testid={`view-product-${product.id}`} | |
> | |
<Eye className="h-4 w-4" /> | |
</Button> | |
<Button | |
variant="outline" | |
size="sm" | |
onClick={() => handleEditProduct(product)} | |
data-testid={`edit-product-${product.id}`} | |
> | |
<Edit className="h-4 w-4" /> | |
</Button> | |
<Button | |
variant="destructive" | |
size="sm" | |
onClick={() => deleteProductMutation.mutate(product.id)} | |
data-testid={`delete-product-${product.id}`} | |
> | |
<Trash2 className="h-4 w-4" /> | |
</Button> | |
</div> | |
</div> | |
))} | |
</div> | |
) : ( | |
<div className="text-center py-12" data-testid="no-products"> | |
<Package className="h-12 w-12 text-gray-400 mx-auto mb-4" /> | |
<p className="text-gray-500">No products available.</p> | |
</div> | |
)} | |
</CardContent> | |
</Card> | |
</TabsContent> | |
<TabsContent value="settings"> | |
<Card className="glass-card border-0"> | |
<CardHeader> | |
<CardTitle>System Settings</CardTitle> | |
</CardHeader> | |
<CardContent> | |
<div className="space-y-6" data-testid="settings-panel"> | |
<div> | |
<h3 className="text-lg font-medium text-foreground mb-2">Platform Statistics</h3> | |
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 md:gap-4"> | |
<div className="glass-card p-4 rounded-2xl"> | |
<div className="text-2xl font-bold text-primary" data-testid="total-sellers"> | |
{sellers.length} | |
</div> | |
<div className="text-sm text-muted-foreground">Total Sellers</div> | |
</div> | |
<div className="glass-card p-4 rounded-2xl"> | |
<div className="text-2xl font-bold text-primary" data-testid="total-products"> | |
{products.length} | |
</div> | |
<div className="text-sm text-muted-foreground">Total Products</div> | |
</div> | |
<div className="glass-card p-4 rounded-2xl"> | |
<div className="text-2xl font-bold text-primary" data-testid="total-categories"> | |
{categories.length} | |
</div> | |
<div className="text-sm text-muted-foreground">Total Categories</div> | |
</div> | |
</div> | |
</div> | |
<Separator /> | |
<div> | |
<h3 className="text-lg font-medium text-foreground mb-2">Quick Actions</h3> | |
<div className="flex flex-wrap gap-2 sm:gap-4"> | |
<Button variant="outline" size={isMobile ? "sm" : "default"} data-testid="backup-data"> | |
Backup Data | |
</Button> | |
<Button variant="outline" size={isMobile ? "sm" : "default"} data-testid="export-reports"> | |
Export Reports | |
</Button> | |
<Button variant="outline" size={isMobile ? "sm" : "default"} data-testid="system-logs"> | |
View System Logs | |
</Button> | |
</div> | |
</div> | |
</div> | |
</CardContent> | |
</Card> | |
</TabsContent> | |
<TabsContent value="orders"> | |
<Card className="glass-card border-0"> | |
<CardHeader> | |
<CardTitle className="flex items-center space-x-2"> | |
<ShoppingCart className="h-5 w-5" /> | |
<span>Order Management</span> | |
</CardTitle> | |
</CardHeader> | |
<CardContent> | |
{ordersLoading ? ( | |
<div className="space-y-4"> | |
{[...Array(5)].map((_, i) => ( | |
<Skeleton key={i} className="h-32 w-full" /> | |
))} | |
</div> | |
) : orders.length === 0 ? ( | |
<div className="text-center py-12"> | |
<ShoppingCart className="h-12 w-12 text-gray-400 mx-auto mb-4" /> | |
<p className="text-gray-500">No orders found.</p> | |
</div> | |
) : ( | |
<div className="space-y-4" data-testid="admin-orders-list"> | |
{orders.map((order: any) => ( | |
<div key={order.id} className="border border-gray-200 rounded-lg p-6" data-testid={`admin-order-${order.id}`}> | |
<div className="flex items-start justify-between mb-4"> | |
<div> | |
<h3 className="font-semibold text-lg"> | |
Order #{order.id.slice(-8)} | |
</h3> | |
<p className="text-sm text-gray-600"> | |
Placed on {new Date(order.createdAt).toLocaleDateString('en-US', { | |
year: 'numeric', | |
month: 'long', | |
day: 'numeric' | |
})} | |
</p> | |
</div> | |
<div className="text-right"> | |
<div className="flex items-center space-x-2 mb-2"> | |
<Badge | |
variant={ | |
order.status === 'delivered' ? 'default' : | |
order.status === 'shipped' ? 'secondary' : | |
order.status === 'pending' ? 'outline' : | |
'destructive' | |
} | |
> | |
{order.status} | |
</Badge> | |
<Badge variant={order.paymentStatus === 'paid' ? 'default' : 'destructive'}> | |
{order.paymentStatus === 'paid' ? 'Paid' : 'Unpaid'} | |
</Badge> | |
</div> | |
<p className="text-lg font-semibold"> | |
₹{parseFloat(order.total).toFixed(2)} | |
</p> | |
</div> | |
</div> | |
{/* Customer Info */} | |
<div className="bg-gray-50 rounded-lg p-4 mb-4"> | |
<h4 className="font-medium mb-2">Customer Information</h4> | |
{order.user && ( | |
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-4 mb-3"> | |
<div className="space-y-1"> | |
<p className="text-sm"><span className="font-medium">Name:</span> {order.user.firstName} {order.user.lastName}</p> | |
<p className="text-sm"><span className="font-medium">Email:</span> {order.user.email}</p> | |
</div> | |
<div className="space-y-1"> | |
<p className="text-sm"><span className="font-medium">Phone:</span> {order.user.phone}</p> | |
<p className="text-sm"><span className="font-medium">Payment:</span> {order.paymentMethod === 'cod' ? 'Cash on Delivery' : 'UPI'}</p> | |
</div> | |
</div> | |
)} | |
<p className="text-sm text-gray-600"><span className="font-medium">Address:</span> {order.shippingAddress}</p> | |
</div> | |
{/* Admin Order Management Actions */} | |
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-2 mb-4 p-3 bg-red-50 rounded-lg"> | |
<div className="flex items-center space-x-2"> | |
<label className="text-sm font-medium whitespace-nowrap">Status:</label> | |
<select | |
className="flex-1 px-3 py-1 text-sm border rounded min-w-0" | |
value={order.status} | |
onChange={(e) => handleUpdateOrderStatus(order.id, { status: e.target.value })} | |
> | |
<option value="pending">Pending</option> | |
<option value="processing">Processing</option> | |
<option value="shipped">Shipped</option> | |
<option value="delivered">Delivered</option> | |
<option value="cancelled">Cancelled</option> | |
</select> | |
</div> | |
<Button | |
size="sm" | |
variant={order.paymentStatus === 'paid' ? 'outline' : 'default'} | |
onClick={() => handleUpdateOrderStatus(order.id, { | |
paymentStatus: order.paymentStatus === 'paid' ? 'unpaid' : 'paid' | |
})} | |
className="w-full sm:w-auto" | |
> | |
Mark as {order.paymentStatus === 'paid' ? 'Unpaid' : 'Paid'} | |
</Button> | |
</div> | |
{/* Order Items */} | |
<div> | |
<h4 className="font-medium mb-3">Items Ordered</h4> | |
<div className="space-y-2"> | |
{order.items?.map((item: any, index: number) => ( | |
<div key={index} className="flex items-center justify-between py-2 border-b border-gray-100 last:border-0"> | |
<div className="flex items-center space-x-3"> | |
<div className="w-12 h-12 bg-gray-100 rounded overflow-hidden"> | |
{item.product?.images?.[0] ? ( | |
<img | |
src={item.product.images[0]} | |
alt={item.product.title} | |
className="w-full h-full object-cover" | |
/> | |
) : ( | |
<div className="w-full h-full bg-gray-200 flex items-center justify-center"> | |
<Package className="h-4 w-4 text-gray-400" /> | |
</div> | |
)} | |
</div> | |
<div> | |
<p className="font-medium text-sm">{item.product?.title}</p> | |
<p className="text-xs text-gray-500">Qty: {item.quantity}</p> | |
</div> | |
</div> | |
<div className="text-right"> | |
<p className="font-medium text-sm">₹{parseFloat(item.price).toFixed(2)}</p> | |
<p className="text-xs text-gray-500">₹{(parseFloat(item.price) * item.quantity).toFixed(2)} total</p> | |
</div> | |
</div> | |
))} | |
</div> | |
</div> | |
<div className="flex justify-end mt-4 pt-4 border-t"> | |
<div className="text-right"> | |
<p className="text-sm text-gray-600">Subtotal: ₹{parseFloat(order.subtotal).toFixed(2)}</p> | |
<p className="text-sm text-gray-600">Tax: ₹{parseFloat(order.tax).toFixed(2)}</p> | |
<p className="text-sm text-gray-600">Shipping: ₹{parseFloat(order.shipping || '0').toFixed(2)}</p> | |
<p className="font-semibold text-lg">Total: ₹{parseFloat(order.total).toFixed(2)}</p> | |
</div> | |
</div> | |
</div> | |
))} | |
</div> | |
)} | |
</CardContent> | |
</Card> | |
</TabsContent> | |
</Tabs> | |
</div> | |
{/* Floating Navigation for All Screen Sizes */} | |
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50"> | |
<nav className="bg-white/80 backdrop-blur-xl border border-gray-200/30 rounded-2xl shadow-lg px-4 py-3 flex items-center space-x-2"> | |
<button | |
onClick={() => setActiveTab("sellers")} | |
className={`relative flex flex-col items-center justify-center p-3 rounded-xl transition-all duration-200 ${ | |
activeTab === "sellers" | |
? 'text-purple-600 bg-purple-50' | |
: 'text-gray-500 hover:text-purple-600 hover:bg-gray-50' | |
}`} | |
data-testid="mobile-nav-sellers" | |
> | |
<Users className="h-5 w-5" /> | |
{activeTab === "sellers" && ( | |
<div className="absolute top-1/2 left-0 transform -translate-y-1/2 w-1 h-6 bg-purple-600 rounded-r-full"></div> | |
)} | |
</button> | |
<button | |
onClick={() => setActiveTab("users")} | |
className={`relative flex flex-col items-center justify-center p-3 rounded-xl transition-all duration-200 ${ | |
activeTab === "users" | |
? 'text-purple-600 bg-purple-50' | |
: 'text-gray-500 hover:text-purple-600 hover:bg-gray-50' | |
}`} | |
data-testid="mobile-nav-users" | |
> | |
<MapPin className="h-5 w-5" /> | |
{activeTab === "users" && ( | |
<div className="absolute top-1/2 left-0 transform -translate-y-1/2 w-1 h-6 bg-purple-600 rounded-r-full"></div> | |
)} | |
</button> | |
<button | |
onClick={() => setActiveTab("categories")} | |
className={`relative flex flex-col items-center justify-center p-3 rounded-xl transition-all duration-200 ${ | |
activeTab === "categories" | |
? 'text-purple-600 bg-purple-50' | |
: 'text-gray-500 hover:text-purple-600 hover:bg-gray-50' | |
}`} | |
data-testid="mobile-nav-categories" | |
> | |
<Store className="h-5 w-5" /> | |
{activeTab === "categories" && ( | |
<div className="absolute top-1/2 left-0 transform -translate-y-1/2 w-1 h-6 bg-purple-600 rounded-r-full"></div> | |
)} | |
</button> | |
<button | |
onClick={() => setActiveTab("products")} | |
className={`relative flex flex-col items-center justify-center p-3 rounded-xl transition-all duration-200 ${ | |
activeTab === "products" | |
? 'text-purple-600 bg-purple-50' | |
: 'text-gray-500 hover:text-purple-600 hover:bg-gray-50' | |
}`} | |
data-testid="mobile-nav-products" | |
> | |
<Package className="h-5 w-5" /> | |
{activeTab === "products" && ( | |
<div className="absolute top-1/2 left-0 transform -translate-y-1/2 w-1 h-6 bg-purple-600 rounded-r-full"></div> | |
)} | |
</button> | |
<button | |
onClick={() => setActiveTab("orders")} | |
className={`relative flex flex-col items-center justify-center p-3 rounded-xl transition-all duration-200 ${ | |
activeTab === "orders" | |
? 'text-purple-600 bg-purple-50' | |
: 'text-gray-500 hover:text-purple-600 hover:bg-gray-50' | |
}`} | |
data-testid="mobile-nav-orders" | |
> | |
<ShoppingCart className="h-5 w-5" /> | |
{activeTab === "orders" && ( | |
<div className="absolute top-1/2 left-0 transform -translate-y-1/2 w-1 h-6 bg-purple-600 rounded-r-full"></div> | |
)} | |
</button> | |
<button | |
onClick={() => setActiveTab("settings")} | |
className={`relative flex flex-col items-center justify-center p-3 rounded-xl transition-all duration-200 ${ | |
activeTab === "settings" | |
? 'text-purple-600 bg-purple-50' | |
: 'text-gray-500 hover:text-purple-600 hover:bg-gray-50' | |
}`} | |
data-testid="mobile-nav-settings" | |
> | |
<Settings className="h-5 w-5" /> | |
{activeTab === "settings" && ( | |
<div className="absolute top-1/2 left-0 transform -translate-y-1/2 w-1 h-6 bg-purple-600 rounded-r-full"></div> | |
)} | |
</button> | |
</nav> | |
</div> | |
</div> | |
); | |
} | |