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:
@@ -1,16 +1,61 @@
|
|||||||
|
"use client";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import React from "react";
|
import React, { Suspense, lazy, useState } from "react";
|
||||||
import AdminAnalytics from "@/components/admin/AdminAnalytics";
|
|
||||||
import InviteVendorCard from "@/components/admin/InviteVendorCard";
|
|
||||||
import BanUserCard from "@/components/admin/BanUserCard";
|
|
||||||
import InvitationsListCard from "@/components/admin/InvitationsListCard";
|
|
||||||
import VendorsCard from "@/components/admin/VendorsCard";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Lazy load admin components
|
||||||
|
const AdminAnalytics = lazy(() => import("@/components/admin/AdminAnalytics"));
|
||||||
|
const InviteVendorCard = lazy(() => import("@/components/admin/InviteVendorCard"));
|
||||||
|
const BanUserCard = lazy(() => import("@/components/admin/BanUserCard"));
|
||||||
|
const InvitationsListCard = lazy(() => import("@/components/admin/InvitationsListCard"));
|
||||||
|
const VendorsCard = lazy(() => import("@/components/admin/VendorsCard"));
|
||||||
|
|
||||||
|
// Loading skeleton for admin components
|
||||||
|
function AdminComponentSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading skeleton for management cards
|
||||||
|
function ManagementCardsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 lg:gap-6 sm:grid-cols-2 lg:grid-cols-3 items-stretch">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState("analytics");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -23,23 +68,27 @@ export default function AdminPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="analytics" className="space-y-6">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="analytics">Analytics</TabsTrigger>
|
<TabsTrigger value="analytics">Analytics</TabsTrigger>
|
||||||
<TabsTrigger value="management">Management</TabsTrigger>
|
<TabsTrigger value="management">Management</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="analytics" className="space-y-6">
|
<TabsContent value="analytics" className="space-y-6">
|
||||||
|
<Suspense fallback={<AdminComponentSkeleton />}>
|
||||||
<AdminAnalytics />
|
<AdminAnalytics />
|
||||||
|
</Suspense>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="management" className="space-y-6">
|
<TabsContent value="management" className="space-y-6">
|
||||||
|
<Suspense fallback={<ManagementCardsSkeleton />}>
|
||||||
<div className="grid gap-4 lg:gap-6 sm:grid-cols-2 lg:grid-cols-3 items-stretch">
|
<div className="grid gap-4 lg:gap-6 sm:grid-cols-2 lg:grid-cols-3 items-stretch">
|
||||||
<VendorsCard />
|
<VendorsCard />
|
||||||
<InviteVendorCard />
|
<InviteVendorCard />
|
||||||
<BanUserCard />
|
<BanUserCard />
|
||||||
<InvitationsListCard />
|
<InvitationsListCard />
|
||||||
</div>
|
</div>
|
||||||
|
</Suspense>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
553
components/admin/OrderDetailsModal.tsx
Normal file
553
components/admin/OrderDetailsModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Search, Filter, Eye, ChevronLeft, ChevronRight } from "lucide-react";
|
import { Search, Filter, Eye, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import OrderDetailsModal from "./OrderDetailsModal";
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
orderId: string | number;
|
orderId: string | number;
|
||||||
@@ -23,6 +24,11 @@ interface Order {
|
|||||||
|
|
||||||
interface OrdersTableProps {
|
interface OrdersTableProps {
|
||||||
orders: Order[];
|
orders: Order[];
|
||||||
|
/**
|
||||||
|
* Enable order details modal (admin-only feature)
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
enableModal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusStyle = (status: string) => {
|
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 [currentPage, setCurrentPage] = useState(1);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
const [selectedOrderId, setSelectedOrderId] = useState<number | string | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const itemsPerPage = 10;
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
// Filter orders based on search and status
|
// 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
|
setCurrentPage(1); // Reset to first page when filtering
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewOrder = (orderId: number | string) => {
|
||||||
|
setSelectedOrderId(orderId);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -153,9 +170,25 @@ export default function OrdersTable({ orders }: OrdersTableProps) {
|
|||||||
<TableCell>N/A</TableCell>
|
<TableCell>N/A</TableCell>
|
||||||
<TableCell>{new Date(order.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell>{new Date(order.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button variant="outline" size="sm">
|
{enableModal ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleViewOrder(order.orderId)}
|
||||||
|
title="View order details (Admin only)"
|
||||||
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled
|
||||||
|
title="Order details modal disabled"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -209,6 +242,15 @@ export default function OrdersTable({ orders }: OrdersTableProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Order Details Modal - Admin only */}
|
||||||
|
{enableModal && selectedOrderId && (
|
||||||
|
<OrderDetailsModal
|
||||||
|
orderId={selectedOrderId}
|
||||||
|
open={isModalOpen}
|
||||||
|
onOpenChange={setIsModalOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,7 +282,9 @@ export async function fetchClient<T>(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For direct API calls, construct full URL
|
// For direct API calls, construct full URL
|
||||||
url = `${apiUrl}${normalizedEndpoint}`;
|
// Ensure /api prefix is included if apiUrl doesn't already have it
|
||||||
|
const baseUrl = apiUrl.endsWith('/api') ? apiUrl : `${apiUrl}/api`;
|
||||||
|
url = `${baseUrl}${normalizedEndpoint}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get auth token from cookies
|
// Get auth token from cookies
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"commitHash": "07dcaf5",
|
"commitHash": "93ec3d3",
|
||||||
"buildTime": "2025-12-15T17:54:38.040Z"
|
"buildTime": "2025-12-17T23:21:50.682Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user