Introduces pagination controls and server-side paginated fetching for blocked users, users, and vendors in the admin dashboard. Improves error handling in server API responses and validates order ID in OrderDetailsModal. Updates git-info.json with latest commit metadata.
565 lines
22 KiB
TypeScript
565 lines
22 KiB
TypeScript
"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 {
|
||
// Validate orderId before making request
|
||
if (!orderId || orderId === 'undefined' || orderId === 'null') {
|
||
throw new Error('Order ID is required');
|
||
}
|
||
|
||
// Ensure orderId is a valid number or string
|
||
const orderIdStr = String(orderId).trim();
|
||
if (!orderIdStr || orderIdStr === 'undefined' || orderIdStr === 'null') {
|
||
throw new Error('Invalid order ID');
|
||
}
|
||
|
||
// 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 #${orderIdStr}`);
|
||
const orderData = await fetchClient<OrderDetails>(`/admin/orders/${orderIdStr}`);
|
||
|
||
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>
|
||
);
|
||
}
|
||
|