551 lines
18 KiB
TypeScript
551 lines
18 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card"
|
|
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 {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
|
import {
|
|
Search,
|
|
Eye,
|
|
Package,
|
|
DollarSign,
|
|
Calendar,
|
|
Mail,
|
|
Truck,
|
|
CheckCircle,
|
|
XCircle,
|
|
Clock,
|
|
AlertCircle,
|
|
} from "lucide-react"
|
|
|
|
interface Order {
|
|
id: string
|
|
customerId: string | null
|
|
customerEmail: string
|
|
items: OrderItem[]
|
|
totalAmount: number
|
|
currency: string
|
|
status: "pending" | "paid" | "fulfilled" | "cancelled" | "refunded"
|
|
paymentIntentId: string | null
|
|
stripeSessionId: string | null
|
|
createdAt: string
|
|
updatedAt: string
|
|
shippingAddress?: {
|
|
name: string
|
|
address: string
|
|
city: string
|
|
state: string
|
|
zipCode: string
|
|
country: string
|
|
}
|
|
}
|
|
|
|
interface OrderItem {
|
|
productId: string
|
|
productName: string
|
|
price: number
|
|
quantity: number
|
|
priceId: string
|
|
}
|
|
|
|
export function OrderManagement() {
|
|
const [orders, setOrders] = useState<Order[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [searchTerm, setSearchTerm] = useState("")
|
|
const [statusFilter, setStatusFilter] = useState<string>("all")
|
|
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null)
|
|
const [showOrderDetail, setShowOrderDetail] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [success, setSuccess] = useState<string | null>(null)
|
|
|
|
// Fetch orders
|
|
const fetchOrders = async () => {
|
|
try {
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
const params = new URLSearchParams()
|
|
if (statusFilter !== "all") params.append("status", statusFilter)
|
|
|
|
const response = await fetch(`/api/orders?${params}`)
|
|
if (!response.ok) throw new Error("Failed to fetch orders")
|
|
const data = await response.json()
|
|
setOrders(data.orders)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to fetch orders")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchOrders()
|
|
}, [statusFilter])
|
|
|
|
// Handle order status update (placeholder)
|
|
const handleUpdateOrderStatus = async (
|
|
orderId: string,
|
|
newStatus: Order["status"]
|
|
) => {
|
|
try {
|
|
setLoading(true)
|
|
|
|
// In a real implementation, you would call an API to update the order
|
|
console.log(`Updating order ${orderId} to status: ${newStatus}`)
|
|
|
|
setOrders((prev) =>
|
|
prev.map((order) =>
|
|
order.id === orderId
|
|
? {
|
|
...order,
|
|
status: newStatus,
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
: order
|
|
)
|
|
)
|
|
|
|
setSuccess(`Order status updated successfully`)
|
|
setShowOrderDetail(false)
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof Error ? err.message : "Failed to update order status"
|
|
)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// View order details
|
|
const handleViewOrder = (order: Order) => {
|
|
setSelectedOrder(order)
|
|
setShowOrderDetail(true)
|
|
}
|
|
|
|
// Filter orders based on search and status
|
|
const filteredOrders = orders.filter((order) => {
|
|
const matchesSearch =
|
|
order.customerEmail.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
order.id.toLowerCase().includes(searchTerm.toLowerCase())
|
|
|
|
const matchesStatus =
|
|
statusFilter === "all" || order.status === statusFilter
|
|
|
|
return matchesSearch && matchesStatus
|
|
})
|
|
|
|
// Format date
|
|
const formatDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleDateString("en-US", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})
|
|
}
|
|
|
|
// Get status badge variant
|
|
const getStatusVariant = (status: Order["status"]) => {
|
|
switch (status) {
|
|
case "pending":
|
|
return "secondary"
|
|
case "paid":
|
|
return "default"
|
|
case "fulfilled":
|
|
return "default"
|
|
case "cancelled":
|
|
return "destructive"
|
|
case "refunded":
|
|
return "destructive"
|
|
default:
|
|
return "secondary"
|
|
}
|
|
}
|
|
|
|
// Get status icon
|
|
const getStatusIcon = (status: Order["status"]) => {
|
|
switch (status) {
|
|
case "pending":
|
|
return <Clock className="h-4 w-4" />
|
|
case "paid":
|
|
return <CheckCircle className="h-4 w-4" />
|
|
case "fulfilled":
|
|
return <Truck className="h-4 w-4" />
|
|
case "cancelled":
|
|
return <XCircle className="h-4 w-4" />
|
|
case "refunded":
|
|
return <AlertCircle className="h-4 w-4" />
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Clear alerts
|
|
useEffect(() => {
|
|
if (error || success) {
|
|
const timer = setTimeout(() => {
|
|
setError(null)
|
|
setSuccess(null)
|
|
}, 5000)
|
|
return () => clearTimeout(timer)
|
|
}
|
|
}, [error, success])
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Alerts */}
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{success && (
|
|
<Alert>
|
|
<CheckCircle className="h-4 w-4" />
|
|
<AlertDescription>{success}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Header */}
|
|
<div>
|
|
<h2 className="text-2xl font-bold">Order Management</h2>
|
|
<p className="text-muted-foreground">View and manage customer orders</p>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex gap-4 items-center">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search orders by email or ID..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="w-48">
|
|
<SelectValue placeholder="Filter by status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Statuses</SelectItem>
|
|
<SelectItem value="pending">Pending</SelectItem>
|
|
<SelectItem value="paid">Paid</SelectItem>
|
|
<SelectItem value="fulfilled">Fulfilled</SelectItem>
|
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
|
<SelectItem value="refunded">Refunded</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Orders Table */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Orders</CardTitle>
|
|
<CardDescription>Manage and track customer orders</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading ? (
|
|
<div className="flex justify-center items-center py-8">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Order ID</TableHead>
|
|
<TableHead>Customer</TableHead>
|
|
<TableHead>Items</TableHead>
|
|
<TableHead>Total</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Date</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredOrders.map((order) => (
|
|
<TableRow key={order.id}>
|
|
<TableCell className="font-mono text-sm">
|
|
{order.id}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<Mail className="h-4 w-4 text-muted-foreground" />
|
|
<div className="max-w-[200px] truncate">
|
|
{order.customerEmail}
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<Package className="h-4 w-4 text-muted-foreground" />
|
|
<span>
|
|
{order.items.length} item
|
|
{order.items.length !== 1 ? "s" : ""}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1">
|
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
|
{order.totalAmount.toFixed(2)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
variant={getStatusVariant(order.status)}
|
|
className="flex items-center gap-1"
|
|
>
|
|
{getStatusIcon(order.status)}
|
|
{order.status.charAt(0).toUpperCase() +
|
|
order.status.slice(1)}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
<div className="flex items-center gap-1">
|
|
<Calendar className="h-3 w-3 text-muted-foreground" />
|
|
{formatDate(order.createdAt)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleViewOrder(order)}
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
|
|
{filteredOrders.length === 0 && !loading && (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
No orders found. {searchTerm && "Try adjusting your search."}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Order Detail Dialog */}
|
|
<Dialog open={showOrderDetail} onOpenChange={setShowOrderDetail}>
|
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
|
{selectedOrder && (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>Order Details - {selectedOrder.id}</DialogTitle>
|
|
<DialogDescription>
|
|
{formatDate(selectedOrder.createdAt)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-6">
|
|
{/* Customer Information */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">
|
|
Customer Information
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<h4 className="font-medium mb-2">Contact</h4>
|
|
<div className="space-y-1 text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<Mail className="h-4 w-4 text-muted-foreground" />
|
|
<span>{selectedOrder.customerEmail}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{selectedOrder.shippingAddress && (
|
|
<div>
|
|
<h4 className="font-medium mb-2">Shipping Address</h4>
|
|
<div className="space-y-1 text-sm">
|
|
<div>{selectedOrder.shippingAddress.name}</div>
|
|
<div>{selectedOrder.shippingAddress.address}</div>
|
|
<div>
|
|
{selectedOrder.shippingAddress.city},{" "}
|
|
{selectedOrder.shippingAddress.state}{" "}
|
|
{selectedOrder.shippingAddress.zipCode}
|
|
</div>
|
|
<div>{selectedOrder.shippingAddress.country}</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Order Items */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Order Items</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Product</TableHead>
|
|
<TableHead>Price</TableHead>
|
|
<TableHead>Quantity</TableHead>
|
|
<TableHead>Total</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{selectedOrder.items.map((item, index) => (
|
|
<TableRow key={index}>
|
|
<TableCell className="font-medium">
|
|
{item.productName}
|
|
</TableCell>
|
|
<TableCell>${item.price.toFixed(2)}</TableCell>
|
|
<TableCell>{item.quantity}</TableCell>
|
|
<TableCell>
|
|
${(item.price * item.quantity).toFixed(2)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
<div className="mt-4 pt-4 border-t">
|
|
<div className="flex justify-between items-center">
|
|
<span className="font-medium">Total:</span>
|
|
<div className="flex items-center gap-1 text-lg">
|
|
<DollarSign className="h-5 w-5 text-muted-foreground" />
|
|
{selectedOrder.totalAmount.toFixed(2)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Order Status */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Order Status</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Badge
|
|
variant={getStatusVariant(selectedOrder.status)}
|
|
className="flex items-center gap-1"
|
|
>
|
|
{getStatusIcon(selectedOrder.status)}
|
|
{selectedOrder.status.charAt(0).toUpperCase() +
|
|
selectedOrder.status.slice(1)}
|
|
</Badge>
|
|
<span className="text-sm text-muted-foreground">
|
|
Last updated: {formatDate(selectedOrder.updatedAt)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
{selectedOrder.status === "paid" && (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
handleUpdateOrderStatus(
|
|
selectedOrder.id,
|
|
"fulfilled"
|
|
)
|
|
}
|
|
>
|
|
Mark as Fulfilled
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
handleUpdateOrderStatus(
|
|
selectedOrder.id,
|
|
"cancelled"
|
|
)
|
|
}
|
|
>
|
|
Cancel Order
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
{selectedOrder.status === "fulfilled" && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
handleUpdateOrderStatus(
|
|
selectedOrder.id,
|
|
"refunded"
|
|
)
|
|
}
|
|
>
|
|
Process Refund
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowOrderDetail(false)}
|
|
>
|
|
Close
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|