Add admin order details modal and improve admin UI

Introduces an admin-only OrderDetailsModal component for viewing and managing order details, including status updates and transaction info. Updates OrdersTable to support the modal, and enhances the admin dashboard page with lazy loading and skeletons for better UX. Also fixes API client base URL handling for /api prefix.
This commit is contained in:
g
2025-12-17 23:38:17 +00:00
parent 93ec3d3642
commit 2db13cc9b7
5 changed files with 667 additions and 21 deletions

View File

@@ -0,0 +1,553 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { fetchClient } from "@/lib/api-client";
import { toast } from "sonner";
import { Package, User, Calendar, DollarSign, MapPin, Truck, CheckCircle, XCircle, Clock, Wallet, Copy, ExternalLink } from "lucide-react";
interface OrderDetails {
_id: string;
orderId: number;
telegramBuyerId: string;
telegramUsername?: string;
totalPrice: number;
cryptoTotal?: number;
orderDate: string;
paidAt?: string;
status: string;
products: Array<{
productId: string;
name: string;
quantity: number;
pricePerUnit: number;
totalItemPrice: number;
}>;
shippingMethod?: {
type?: string;
name?: string; // Legacy support
price: number;
};
pgpAddress?: string;
trackingNumber?: string;
discountAmount?: number;
subtotalBeforeDiscount?: number;
paymentAddress?: string;
txid?: string | string[];
vendorId?: {
username?: string;
};
storeId?: string;
}
interface OrderDetailsModalProps {
orderId: number | string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Admin-only order details modal component
* This component fetches order details from admin-only API endpoints
* and should only be used in admin contexts
*/
// Helper function to format currency, removing unnecessary .00
const formatCurrency = (amount: number | undefined | null): string => {
if (amount === undefined || amount === null || isNaN(amount) || amount === 0) {
return '';
}
// If it's a whole number, don't show decimals
if (amount % 1 === 0) {
return `£${amount.toFixed(0)}`;
}
return `£${amount.toFixed(2)}`;
};
const getStatusConfig = (status: string) => {
switch (status) {
case 'acknowledged':
return { label: 'Acknowledged', color: 'bg-purple-500/10 text-purple-500 border-purple-500/20', icon: CheckCircle };
case 'paid':
return { label: 'Paid', color: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20', icon: CheckCircle };
case 'shipped':
return { label: 'Shipped', color: 'bg-blue-500/10 text-blue-500 border-blue-500/20', icon: Truck };
case 'completed':
return { label: 'Completed', color: 'bg-green-500/10 text-green-500 border-green-500/20', icon: CheckCircle };
case 'cancelled':
return { label: 'Cancelled', color: 'bg-red-500/10 text-red-500 border-red-500/20', icon: XCircle };
case 'unpaid':
return { label: 'Unpaid', color: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20', icon: Clock };
case 'confirming':
return { label: 'Confirming', color: 'bg-orange-500/10 text-orange-500 border-orange-500/20', icon: Clock };
default:
return { label: status, color: 'bg-gray-500/10 text-gray-500 border-gray-500/20', icon: Package };
}
};
export default function OrderDetailsModal({ orderId, open, onOpenChange }: OrderDetailsModalProps) {
const [order, setOrder] = useState<OrderDetails | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [updatingStatus, setUpdatingStatus] = useState(false);
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(label);
setTimeout(() => setCopied(null), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const handleStatusChange = async (newStatus: string) => {
if (!order || newStatus === order.status) return;
setUpdatingStatus(true);
try {
const response = await fetchClient<{ message: string; order: { orderId: number; status: string; oldStatus: string } }>(
`/admin/orders/${orderId}/status`,
{
method: 'PUT',
body: { status: newStatus }
}
);
// Update local order state
setOrder({ ...order, status: newStatus });
toast.success(`Order status updated to ${newStatus}`);
// Optionally refresh order details to get latest data
await fetchOrderDetails();
} catch (err) {
console.error('Failed to update order status:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to update order status';
toast.error(errorMessage);
} finally {
setUpdatingStatus(false);
}
};
useEffect(() => {
if (open && orderId) {
fetchOrderDetails();
} else {
// Reset state when modal closes
setOrder(null);
setError(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, orderId]);
const fetchOrderDetails = async () => {
setLoading(true);
setError(null);
try {
// Fetch full order details from admin endpoint
// Use /admin/orders/:orderId (fetchClient will add /api prefix and backend URL)
console.log(`Fetching order details for order #${orderId}`);
const orderData = await fetchClient<OrderDetails>(`/admin/orders/${orderId}`);
console.log("Order data received:", orderData);
if (!orderData) {
throw new Error("No order data received from server");
}
if (!orderData.orderId) {
throw new Error("Invalid order data: missing orderId");
}
setOrder(orderData);
} catch (err) {
console.error("Failed to fetch order details:", err);
const errorMessage = err instanceof Error
? err.message
: typeof err === 'string'
? err
: "Failed to load order details. Please check your connection and try again.";
setError(errorMessage);
} finally {
setLoading(false);
}
};
const statusConfig = order ? getStatusConfig(order.status) : null;
const StatusIcon = statusConfig?.icon || Package;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Order Details #{orderId}</DialogTitle>
<DialogDescription>
Complete order information and status
</DialogDescription>
</DialogHeader>
{loading && (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-32 w-full" />
</div>
)}
{error && (
<Card>
<CardContent className="pt-6">
<div className="text-center space-y-2">
<p className="text-red-500 font-medium">{error}</p>
<p className="text-sm text-muted-foreground">
Please check the browser console for more details.
</p>
</div>
</CardContent>
</Card>
)}
{order && !loading && (
<div className="space-y-4">
{/* Order Status */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Order Status</CardTitle>
{statusConfig && (
<Badge className={`${statusConfig.color} border`}>
<StatusIcon className="h-3 w-3 mr-1" />
{statusConfig.label}
</Badge>
)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground mb-3">Change Status:</p>
<div className="flex flex-wrap gap-2">
<Button
variant={order.status === "unpaid" ? "default" : "outline"}
size="sm"
onClick={() => handleStatusChange("unpaid")}
disabled={updatingStatus || order.status === "unpaid"}
>
Unpaid
</Button>
<Button
variant={order.status === "confirming" ? "default" : "outline"}
size="sm"
onClick={() => handleStatusChange("confirming")}
disabled={updatingStatus || order.status === "confirming"}
>
Confirming
</Button>
<Button
variant={order.status === "acknowledged" ? "default" : "outline"}
size="sm"
onClick={() => handleStatusChange("acknowledged")}
disabled={updatingStatus || order.status === "acknowledged"}
>
Acknowledged
</Button>
<Button
variant={order.status === "paid" ? "default" : "outline"}
size="sm"
onClick={() => handleStatusChange("paid")}
disabled={updatingStatus || order.status === "paid"}
>
Paid
</Button>
<Button
variant={order.status === "shipped" ? "default" : "outline"}
size="sm"
onClick={() => handleStatusChange("shipped")}
disabled={updatingStatus || order.status === "shipped"}
>
Shipped
</Button>
<Button
variant={order.status === "completed" ? "default" : "outline"}
size="sm"
onClick={() => handleStatusChange("completed")}
disabled={updatingStatus || order.status === "completed"}
>
Completed
</Button>
<Button
variant={order.status === "cancelled" ? "default" : "outline"}
size="sm"
onClick={() => handleStatusChange("cancelled")}
disabled={updatingStatus || order.status === "cancelled"}
className="text-red-600 hover:text-red-700"
>
Cancelled
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Order Information */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Order Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center space-x-2">
<User className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm text-muted-foreground">Customer</p>
<p className="font-medium">{order.telegramBuyerId}</p>
{order.telegramUsername && (
<p className="text-xs text-muted-foreground">@{order.telegramUsername}</p>
)}
</div>
</div>
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm text-muted-foreground">Order Date</p>
<p className="font-medium">{new Date(order.orderDate).toLocaleString()}</p>
{order.paidAt && (
<p className="text-xs text-muted-foreground">
Paid: {new Date(order.paidAt).toLocaleString()}
</p>
)}
</div>
</div>
</div>
{order.vendorId?.username && (
<div className="flex items-center space-x-2">
<Package className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm text-muted-foreground">Vendor</p>
<p className="font-medium">{order.vendorId.username}</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Products */}
{order.products && order.products.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Products</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{order.products.map((product, index) => (
<div key={index} className="flex items-center justify-between border-b pb-3 last:border-0">
<div className="flex-1">
<p className="font-medium">{product.name || `Product ${index + 1}`}</p>
<p className="text-sm text-muted-foreground">
Quantity: {product.quantity} × {formatCurrency(product.pricePerUnit)}
</p>
</div>
<p className="font-semibold">{formatCurrency(product.totalItemPrice)}</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Shipping & Pricing */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Pricing Details</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{order.subtotalBeforeDiscount !== undefined &&
order.subtotalBeforeDiscount !== null &&
typeof order.subtotalBeforeDiscount === 'number' &&
order.subtotalBeforeDiscount > 0 &&
formatCurrency(order.subtotalBeforeDiscount) ? (
<div className="flex justify-between">
<span className="text-muted-foreground">Subtotal</span>
<span>{formatCurrency(order.subtotalBeforeDiscount)}</span>
</div>
) : null}
{order.shippingMethod &&
order.shippingMethod.price !== undefined &&
order.shippingMethod.price !== null &&
typeof order.shippingMethod.price === 'number' &&
order.shippingMethod.price > 0 &&
formatCurrency(order.shippingMethod.price) ? (
<div className="flex justify-between">
<span className="text-muted-foreground flex items-center">
<Truck className="h-3 w-3 mr-1" />
Shipping {order.shippingMethod.type || order.shippingMethod.name ? `(${order.shippingMethod.type || order.shippingMethod.name})` : ''}
</span>
<span>{formatCurrency(order.shippingMethod.price)}</span>
</div>
) : null}
{order.discountAmount !== undefined &&
order.discountAmount !== null &&
typeof order.discountAmount === 'number' &&
order.discountAmount > 0 &&
formatCurrency(order.discountAmount) ? (
<div className="flex justify-between text-green-600">
<span>Discount</span>
<span>-{formatCurrency(order.discountAmount)}</span>
</div>
) : null}
<div className="flex justify-between pt-2 border-t font-semibold text-lg">
<span className="flex items-center">
<DollarSign className="h-4 w-4 mr-1" />
Total
</span>
<span>{formatCurrency(order.totalPrice)}</span>
</div>
{order.cryptoTotal && (
<div className="text-sm text-muted-foreground pt-1">
Crypto: {order.cryptoTotal} LTC
</div>
)}
</CardContent>
</Card>
{/* Shipping Address */}
{order.pgpAddress && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center">
<MapPin className="h-4 w-4 mr-2" />
Shipping Address
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm font-mono break-all">{order.pgpAddress}</p>
</CardContent>
</Card>
)}
{/* Crypto Payment Address */}
{order.paymentAddress && order.paymentAddress !== "NO_PAYMENT_REQUIRED" && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center">
<Wallet className="h-4 w-4 mr-2" />
Crypto Payment Address
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-mono break-all flex-1">{order.paymentAddress}</p>
<div className="flex gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(order.paymentAddress!, 'address')}
>
{copied === 'address' ? (
<>
<CheckCircle className="h-4 w-4 mr-1" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => window.open(`https://litecoinspace.org/address/${order.paymentAddress}`, '_blank')}
>
<ExternalLink className="h-4 w-4 mr-1" />
View on Explorer
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Transaction IDs */}
{order.txid && Array.isArray(order.txid) && order.txid.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center">
<Wallet className="h-4 w-4 mr-2" />
Transaction IDs
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{order.txid.map((tx, index) => (
<div key={index} className="flex items-center justify-between gap-2">
<p className="text-sm font-mono break-all flex-1">{tx}</p>
<div className="flex gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(tx, `tx-${index}`)}
>
{copied === `tx-${index}` ? (
<>
<CheckCircle className="h-4 w-4 mr-1" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => window.open(`https://litecoinspace.org/tx/${tx}`, '_blank')}
>
<ExternalLink className="h-4 w-4 mr-1" />
View
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Tracking */}
{order.trackingNumber && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center">
<Truck className="h-4 w-4 mr-2" />
Tracking Information
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm font-mono">{order.trackingNumber}</p>
</CardContent>
</Card>
)}
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, Filter, Eye, ChevronLeft, ChevronRight } from "lucide-react";
import OrderDetailsModal from "./OrderDetailsModal";
interface Order {
orderId: string | number;
@@ -23,6 +24,11 @@ interface Order {
interface OrdersTableProps {
orders: Order[];
/**
* Enable order details modal (admin-only feature)
* @default true
*/
enableModal?: boolean;
}
const getStatusStyle = (status: string) => {
@@ -46,10 +52,16 @@ const getStatusStyle = (status: string) => {
}
};
export default function OrdersTable({ orders }: OrdersTableProps) {
/**
* Admin-only orders table component with order details modal
* This component should only be used in admin contexts
*/
export default function OrdersTable({ orders, enableModal = true }: OrdersTableProps) {
const [currentPage, setCurrentPage] = useState(1);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [selectedOrderId, setSelectedOrderId] = useState<number | string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const itemsPerPage = 10;
// Filter orders based on search and status
@@ -83,6 +95,11 @@ export default function OrdersTable({ orders }: OrdersTableProps) {
setCurrentPage(1); // Reset to first page when filtering
};
const handleViewOrder = (orderId: number | string) => {
setSelectedOrderId(orderId);
setIsModalOpen(true);
};
return (
<Card>
<CardHeader>
@@ -153,9 +170,25 @@ export default function OrdersTable({ orders }: OrdersTableProps) {
<TableCell>N/A</TableCell>
<TableCell>{new Date(order.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm">
<Eye className="h-4 w-4" />
</Button>
{enableModal ? (
<Button
variant="outline"
size="sm"
onClick={() => handleViewOrder(order.orderId)}
title="View order details (Admin only)"
>
<Eye className="h-4 w-4" />
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled
title="Order details modal disabled"
>
<Eye className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
))}
@@ -209,6 +242,15 @@ export default function OrdersTable({ orders }: OrdersTableProps) {
</div>
)}
</CardContent>
{/* Order Details Modal - Admin only */}
{enableModal && selectedOrderId && (
<OrderDetailsModal
orderId={selectedOrderId}
open={isModalOpen}
onOpenChange={setIsModalOpen}
/>
)}
</Card>
);
}