All checks were successful
Build Frontend / build (push) Successful in 1m8s
Updated the OrderTable component with a new dark-themed UI, improved color schemes, and enhanced button and table styles. Added browser detection to provide a simplified animation experience for Firefox users, ensuring compatibility and smoother rendering. Improved loading state visuals and refined table header and cell styling for better readability.
793 lines
34 KiB
TypeScript
793 lines
34 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import React from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Eye,
|
|
Loader2,
|
|
CheckCircle2,
|
|
XCircle,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ArrowUpDown,
|
|
Truck,
|
|
MessageCircle,
|
|
AlertTriangle,
|
|
Tag,
|
|
Percent
|
|
} from "lucide-react";
|
|
import Link from "next/link";
|
|
import { clientFetch } from '@/lib/api';
|
|
import { exportOrdersToCSV } from '@/lib/api-client';
|
|
import { toast } from "sonner";
|
|
import { Download } from "lucide-react";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { cacheUtils } from '@/lib/api-client';
|
|
|
|
interface Order {
|
|
_id: string;
|
|
orderId: string;
|
|
status: string;
|
|
totalPrice: number;
|
|
orderDate: Date;
|
|
paidAt?: Date;
|
|
telegramUsername?: string;
|
|
telegramBuyerId?: string;
|
|
underpaid?: boolean;
|
|
underpaymentAmount?: number;
|
|
lastBalanceReceived?: number;
|
|
cryptoTotal?: number;
|
|
// Promotion fields
|
|
promotion?: string;
|
|
promotionCode?: string;
|
|
discountAmount?: number;
|
|
subtotalBeforeDiscount?: number;
|
|
}
|
|
|
|
|
|
|
|
type SortableColumns = "orderId" | "totalPrice" | "status" | "orderDate" | "paidAt";
|
|
|
|
interface StatusConfig {
|
|
icon: React.ElementType;
|
|
color: string;
|
|
bgColor: string;
|
|
animate?: string;
|
|
}
|
|
|
|
type OrderStatus = "paid" | "unpaid" | "acknowledged" | "shipped" | "completed" | "cancelled" | "confirming";
|
|
|
|
// Create a StatusFilter component to replace the missing component
|
|
const StatusFilter = ({ currentStatus, onChange }: { currentStatus: string, onChange: (value: string) => void }) => {
|
|
return (
|
|
<Select value={currentStatus} onValueChange={onChange}>
|
|
<SelectTrigger className="w-40">
|
|
<SelectValue placeholder="Filter Status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Statuses</SelectItem>
|
|
{["paid", "unpaid", "acknowledged", "shipped", "completed", "cancelled", "confirming"].map(status => (
|
|
<SelectItem key={status} value={status}>
|
|
{status.charAt(0).toUpperCase() + status.slice(1)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
};
|
|
|
|
// Create a PageSizeSelector component
|
|
const PageSizeSelector = ({ currentSize, onChange, options }: { currentSize: number, onChange: (value: string) => void, options: number[] }) => {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<div className="text-sm font-medium text-muted-foreground">Show:</div>
|
|
<Select value={currentSize.toString()} onValueChange={onChange}>
|
|
<SelectTrigger className="w-24">
|
|
<SelectValue placeholder="Page Size" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.map(size => (
|
|
<SelectItem key={size} value={size.toString()}>
|
|
{size}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
|
|
|
|
export default function OrderTable() {
|
|
const [orders, setOrders] = useState<Order[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [statusFilter, setStatusFilter] = useState("all");
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [totalOrders, setTotalOrders] = useState(0);
|
|
const [sortConfig, setSortConfig] = useState<{
|
|
column: SortableColumns;
|
|
direction: "asc" | "desc";
|
|
}>({ column: "orderDate", direction: "desc" });
|
|
const [selectedOrders, setSelectedOrders] = useState<Set<string>>(new Set());
|
|
const [isShipping, setIsShipping] = useState(false);
|
|
const [itemsPerPage, setItemsPerPage] = useState<number>(20);
|
|
const pageSizeOptions = [5, 10, 15, 20, 25, 50, 75, 100];
|
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
const [exporting, setExporting] = useState(false);
|
|
|
|
// Add order refresh subscription
|
|
useEffect(() => {
|
|
const unsubscribe = cacheUtils.onOrderRefresh(() => {
|
|
console.log("Order data refresh triggered in OrderTable");
|
|
setRefreshTrigger(prev => prev + 1);
|
|
});
|
|
|
|
return unsubscribe;
|
|
}, []);
|
|
|
|
// Fetch orders with server-side pagination
|
|
// State for browser detection
|
|
const [isFirefox, setIsFirefox] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setIsFirefox(navigator.userAgent.toLowerCase().indexOf("firefox") > -1);
|
|
}, []);
|
|
|
|
const fetchOrders = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const queryParams = new URLSearchParams({
|
|
page: currentPage.toString(),
|
|
limit: itemsPerPage.toString(),
|
|
sortBy: sortConfig.column,
|
|
sortOrder: sortConfig.direction,
|
|
...(statusFilter !== "all" && { status: statusFilter }),
|
|
});
|
|
|
|
const data = await clientFetch(`/orders?${queryParams}`);
|
|
|
|
console.log("Fetched orders with fresh data:", data.orders?.length || 0);
|
|
setOrders(data.orders || []);
|
|
setTotalPages(data.totalPages || 1);
|
|
setTotalOrders(data.totalOrders || 0);
|
|
} catch (error) {
|
|
toast.error("Failed to fetch orders");
|
|
console.error("Fetch error:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [currentPage, statusFilter, itemsPerPage, sortConfig, refreshTrigger]);
|
|
|
|
useEffect(() => {
|
|
fetchOrders();
|
|
}, [fetchOrders]);
|
|
|
|
// Reset to first page when filter changes
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [statusFilter]);
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
setCurrentPage(newPage);
|
|
};
|
|
|
|
const handleItemsPerPageChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
const newSize = parseInt(e.target.value, 10);
|
|
setItemsPerPage(newSize);
|
|
setCurrentPage(1); // Reset to first page when changing items per page
|
|
};
|
|
|
|
// Derived data calculations
|
|
const filteredOrders = orders.filter(
|
|
(order) => statusFilter === "all" || order.status === statusFilter
|
|
);
|
|
|
|
// Use the orders directly as they're already sorted by the server
|
|
const paginatedOrders = filteredOrders;
|
|
|
|
// Handlers
|
|
const handleSort = (column: SortableColumns) => {
|
|
setSortConfig(prev => ({
|
|
column,
|
|
direction: prev.column === column && prev.direction === "asc" ? "desc" : "asc"
|
|
}));
|
|
setCurrentPage(1); // Reset to first page when changing sort order
|
|
};
|
|
|
|
const toggleSelection = (orderId: string) => {
|
|
setSelectedOrders(prev => {
|
|
const newSet = new Set(prev);
|
|
newSet.has(orderId) ? newSet.delete(orderId) : newSet.add(orderId);
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
const toggleAll = () => {
|
|
if (selectedOrders.size === paginatedOrders.length) {
|
|
setSelectedOrders(new Set());
|
|
} else {
|
|
setSelectedOrders(new Set(paginatedOrders.map(o => o._id)));
|
|
}
|
|
};
|
|
|
|
const markAsShipped = async () => {
|
|
if (selectedOrders.size === 0) {
|
|
toast.warning("Please select orders to mark as shipped");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsShipping(true);
|
|
const response = await clientFetch("/orders/mark-shipped", {
|
|
method: "POST",
|
|
body: JSON.stringify({ orderIds: Array.from(selectedOrders) })
|
|
});
|
|
|
|
// Only update orders that were successfully marked as shipped
|
|
if (response.success && response.success.orders) {
|
|
const successfulOrderIds = new Set(response.success.orders.map((o: any) => o.id));
|
|
|
|
setOrders(prev =>
|
|
prev.map(order =>
|
|
successfulOrderIds.has(order._id)
|
|
? { ...order, status: "shipped" }
|
|
: order
|
|
)
|
|
);
|
|
|
|
if (response.failed && response.failed.count > 0) {
|
|
toast.warning(`${response.failed.count} orders could not be marked as shipped`);
|
|
}
|
|
|
|
if (response.success.count > 0) {
|
|
toast.success(`${response.success.count} orders marked as shipped`);
|
|
}
|
|
}
|
|
|
|
setSelectedOrders(new Set());
|
|
} catch (error) {
|
|
toast.error("Failed to update orders");
|
|
console.error("Shipping error:", error);
|
|
} finally {
|
|
setIsShipping(false);
|
|
}
|
|
};
|
|
|
|
const statusConfig: Record<OrderStatus, StatusConfig> = {
|
|
acknowledged: {
|
|
icon: CheckCircle2,
|
|
color: "text-purple-100",
|
|
bgColor: "bg-purple-600/90 shadow-[0_0_10px_rgba(147,51,234,0.3)]"
|
|
},
|
|
paid: {
|
|
icon: CheckCircle2,
|
|
color: "text-emerald-100",
|
|
bgColor: "bg-emerald-600/90 shadow-[0_0_10px_rgba(16,185,129,0.3)]",
|
|
animate: "animate-pulse"
|
|
},
|
|
unpaid: {
|
|
icon: XCircle,
|
|
color: "text-amber-100",
|
|
bgColor: "bg-amber-500/90"
|
|
},
|
|
confirming: {
|
|
icon: Loader2,
|
|
color: "text-blue-100",
|
|
bgColor: "bg-blue-500/90",
|
|
animate: "animate-spin"
|
|
},
|
|
shipped: {
|
|
icon: Truck,
|
|
color: "text-indigo-100",
|
|
bgColor: "bg-indigo-600/90 shadow-[0_0_10px_rgba(79,70,229,0.3)]"
|
|
},
|
|
completed: {
|
|
icon: CheckCircle2,
|
|
color: "text-green-100",
|
|
bgColor: "bg-green-600/90"
|
|
},
|
|
cancelled: {
|
|
icon: XCircle,
|
|
color: "text-gray-100",
|
|
bgColor: "bg-gray-600/90"
|
|
}
|
|
};
|
|
|
|
// Helper function to determine if order is underpaid
|
|
const isOrderUnderpaid = (order: Order) => {
|
|
// More robust check - only show underpaid if status allows it and underpayment exists
|
|
return order.underpaid === true &&
|
|
order.underpaymentAmount &&
|
|
order.underpaymentAmount > 0 &&
|
|
order.status !== "paid" &&
|
|
order.status !== "completed" &&
|
|
order.status !== "shipped" &&
|
|
order.status !== "cancelled";
|
|
};
|
|
|
|
// Helper function to get underpaid display info
|
|
const getUnderpaidInfo = (order: Order) => {
|
|
if (!isOrderUnderpaid(order)) return null;
|
|
|
|
const received = order.lastBalanceReceived || 0;
|
|
const required = order.cryptoTotal || 0;
|
|
const missing = order.underpaymentAmount || 0;
|
|
|
|
// Calculate LTC to GBP exchange rate from order data
|
|
const ltcToGbpRate = required > 0 ? order.totalPrice / required : 0;
|
|
const missingGbp = missing * ltcToGbpRate;
|
|
|
|
return {
|
|
received,
|
|
required,
|
|
missing,
|
|
missingGbp,
|
|
percentage: required > 0 ? ((received / required) * 100).toFixed(1) : 0
|
|
};
|
|
};
|
|
|
|
// Add manual refresh function
|
|
const handleRefresh = () => {
|
|
console.log("Manual refresh triggered");
|
|
setRefreshTrigger(prev => prev + 1);
|
|
toast.success("Orders refreshed");
|
|
};
|
|
|
|
// Handle CSV export
|
|
const handleExportCSV = async () => {
|
|
if (statusFilter === "all") {
|
|
toast.error("Please select a specific status to export");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setExporting(true);
|
|
await exportOrdersToCSV(statusFilter);
|
|
toast.success(`Orders exported successfully!`);
|
|
} catch (error) {
|
|
console.error("Error exporting orders:", error);
|
|
toast.error("Failed to export orders");
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
};
|
|
|
|
// Add periodic refresh for underpaid orders
|
|
useEffect(() => {
|
|
// Check if we have any underpaid orders
|
|
const hasUnderpaidOrders = orders.some(order => isOrderUnderpaid(order));
|
|
|
|
if (hasUnderpaidOrders) {
|
|
console.log("Found underpaid orders, setting up refresh interval");
|
|
const interval = setInterval(() => {
|
|
console.log("Auto-refreshing due to underpaid orders");
|
|
setRefreshTrigger(prev => prev + 1);
|
|
}, 30000); // Refresh every 30 seconds if there are underpaid orders
|
|
|
|
return () => clearInterval(interval);
|
|
}
|
|
}, [orders]);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card className="border-white/10 bg-black/40 backdrop-blur-xl shadow-2xl overflow-hidden rounded-xl">
|
|
{/* Filters header */}
|
|
<div className="p-4 border-b border-white/5 bg-white/[0.02]">
|
|
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4">
|
|
<div className="flex flex-col sm:flex-row gap-2 sm:items-center w-full lg:w-auto">
|
|
<StatusFilter
|
|
currentStatus={statusFilter}
|
|
onChange={setStatusFilter}
|
|
/>
|
|
|
|
<PageSizeSelector
|
|
currentSize={itemsPerPage}
|
|
onChange={(value) => handleItemsPerPageChange({ target: { value } } as React.ChangeEvent<HTMLSelectElement>)}
|
|
options={pageSizeOptions}
|
|
/>
|
|
|
|
{statusFilter !== "all" && (
|
|
<Button
|
|
onClick={handleExportCSV}
|
|
disabled={exporting}
|
|
variant="outline"
|
|
size="sm"
|
|
className="bg-black/20 border-white/10 hover:bg-white/5 hover:text-white transition-colors"
|
|
>
|
|
{exporting ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Exporting...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
Export CSV
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 self-end lg:self-auto">
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button disabled={selectedOrders.size === 0 || isShipping} className="shadow-lg bg-indigo-600 hover:bg-indigo-700 text-white border-0 transition-all hover:scale-105 active:scale-95">
|
|
<Truck className="mr-2 h-4 w-4" />
|
|
{isShipping ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
`Mark Shipped (${selectedOrders.size})`
|
|
)}
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Mark Orders as Shipped</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to mark {selectedOrders.size} order{selectedOrders.size !== 1 ? 's' : ''} as shipped?
|
|
This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={markAsShipped}>
|
|
Confirm
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<CardContent className="p-0 relative min-h-[400px]">
|
|
{loading && (
|
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-[2px] flex items-center justify-center z-50">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<Loader2 className="h-8 w-8 animate-spin text-indigo-500" />
|
|
<span className="text-zinc-400 text-sm font-medium">Loading orders...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="max-h-[calc(100vh-350px)] overflow-auto">
|
|
<Table>
|
|
<TableHeader className="bg-white/[0.02] sticky top-0 z-20 backdrop-blur-md">
|
|
<TableRow className="hover:bg-transparent border-white/5">
|
|
<TableHead className="w-12">
|
|
<Checkbox
|
|
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
|
|
onCheckedChange={toggleAll}
|
|
className="border-white/20 data-[state=checked]:bg-indigo-500 data-[state=checked]:border-indigo-500"
|
|
/>
|
|
</TableHead>
|
|
<TableHead className="cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("orderId")}>
|
|
Order ID <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
|
|
</TableHead>
|
|
<TableHead className="cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("totalPrice")}>
|
|
Total <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
|
|
</TableHead>
|
|
<TableHead className="hidden lg:table-cell text-zinc-400">Promotion</TableHead>
|
|
<TableHead className="cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("status")}>
|
|
Status <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
|
|
</TableHead>
|
|
<TableHead className="hidden md:table-cell cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("orderDate")}>
|
|
Date <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
|
|
</TableHead>
|
|
<TableHead className="hidden xl:table-cell cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("paidAt")}>
|
|
Paid At <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
|
|
</TableHead>
|
|
<TableHead className="hidden lg:table-cell text-zinc-400">Buyer</TableHead>
|
|
<TableHead className="w-24 text-center text-zinc-400">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{isFirefox ? (
|
|
paginatedOrders.map((order, index) => {
|
|
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
|
|
const underpaidInfo = getUnderpaidInfo(order);
|
|
|
|
return (
|
|
<motion.tr
|
|
key={order._id}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="group hover:bg-white/[0.03] border-b border-white/5 transition-colors"
|
|
>
|
|
<TableCell>
|
|
<Checkbox
|
|
checked={selectedOrders.has(order._id)}
|
|
onCheckedChange={() => toggleSelection(order._id)}
|
|
disabled={order.status !== "paid" && order.status !== "acknowledged"}
|
|
className="border-white/20 data-[state=checked]:bg-indigo-500 data-[state=checked]:border-indigo-500"
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm font-medium text-zinc-300">#{order.orderId}</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium text-zinc-200">£{order.totalPrice.toFixed(2)}</span>
|
|
{underpaidInfo && (
|
|
<span className="text-[10px] text-red-400 flex items-center gap-1">
|
|
<AlertTriangle className="h-3 w-3" />
|
|
-£{underpaidInfo.missingGbp.toFixed(2)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="hidden lg:table-cell">
|
|
{order.promotionCode ? (
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center gap-1">
|
|
<Tag className="h-3 w-3 text-emerald-400" />
|
|
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20">
|
|
{order.promotionCode}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 text-[10px] text-emerald-400/80">
|
|
<Percent className="h-2.5 w-2.5" />
|
|
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<span className="text-xs text-zinc-600">-</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border shadow-sm ${statusConfig[order.status as OrderStatus]?.bgColor || "bg-muted text-muted-foreground border-border"} ${statusConfig[order.status as OrderStatus]?.color || ""}`}>
|
|
{React.createElement(statusConfig[order.status as OrderStatus]?.icon || XCircle, {
|
|
className: `h-3.5 w-3.5 ${statusConfig[order.status as OrderStatus]?.animate || ""}`
|
|
})}
|
|
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
|
|
</div>
|
|
{isOrderUnderpaid(order) && (
|
|
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 font-medium">
|
|
{underpaidInfo?.percentage}%
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="hidden md:table-cell text-sm text-zinc-400">
|
|
{new Date(order.orderDate).toLocaleDateString("en-GB", {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
})}
|
|
<span className="ml-1 opacity-50 text-[10px]">
|
|
{new Date(order.orderDate).toLocaleTimeString("en-GB", { hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="hidden xl:table-cell text-sm text-zinc-400">
|
|
{order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
}) : "-"}
|
|
</TableCell>
|
|
<TableCell className="hidden lg:table-cell">
|
|
{order.telegramUsername ? (
|
|
<span className="text-sm font-medium text-indigo-400 hover:text-indigo-300 transition-colors cursor-pointer">@{order.telegramUsername}</span>
|
|
) : (
|
|
<span className="text-xs text-zinc-500 italic">Guest</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-white hover:bg-white/10" asChild>
|
|
<Link href={`/dashboard/orders/${order._id}`}>
|
|
<Eye className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
|
|
{(order.telegramBuyerId || order.telegramUsername) && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-zinc-400 hover:text-indigo-400 hover:bg-indigo-500/10"
|
|
asChild
|
|
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
|
|
>
|
|
<Link href={`/dashboard/chats/new?buyerId=${order.telegramBuyerId || order.telegramUsername}`}>
|
|
<MessageCircle className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</motion.tr>
|
|
);
|
|
})
|
|
) : (
|
|
<AnimatePresence mode="popLayout">
|
|
{paginatedOrders.map((order, index) => {
|
|
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
|
|
const underpaidInfo = getUnderpaidInfo(order);
|
|
|
|
return (
|
|
<motion.tr
|
|
key={order._id}
|
|
layout
|
|
initial={{ opacity: 0, scale: 0.98 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.98 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="group hover:bg-white/[0.03] border-b border-white/5 transition-colors"
|
|
>
|
|
<TableCell>
|
|
<Checkbox
|
|
checked={selectedOrders.has(order._id)}
|
|
onCheckedChange={() => toggleSelection(order._id)}
|
|
disabled={order.status !== "paid" && order.status !== "acknowledged"}
|
|
className="border-white/20 data-[state=checked]:bg-indigo-500 data-[state=checked]:border-indigo-500"
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm font-medium text-zinc-300">#{order.orderId}</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium text-zinc-200">£{order.totalPrice.toFixed(2)}</span>
|
|
{underpaidInfo && (
|
|
<span className="text-[10px] text-red-400 flex items-center gap-1">
|
|
<AlertTriangle className="h-3 w-3" />
|
|
-£{underpaidInfo.missingGbp.toFixed(2)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="hidden lg:table-cell">
|
|
{order.promotionCode ? (
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center gap-1">
|
|
<Tag className="h-3 w-3 text-emerald-400" />
|
|
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20">
|
|
{order.promotionCode}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 text-[10px] text-emerald-400/80">
|
|
<Percent className="h-2.5 w-2.5" />
|
|
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<span className="text-xs text-zinc-600">-</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border shadow-sm ${statusConfig[order.status as OrderStatus]?.bgColor || "bg-muted text-muted-foreground border-border"} ${statusConfig[order.status as OrderStatus]?.color || ""}`}>
|
|
{React.createElement(statusConfig[order.status as OrderStatus]?.icon || XCircle, {
|
|
className: `h-3.5 w-3.5 ${statusConfig[order.status as OrderStatus]?.animate || ""}`
|
|
})}
|
|
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
|
|
</div>
|
|
{isOrderUnderpaid(order) && (
|
|
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 font-medium">
|
|
{underpaidInfo?.percentage}%
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="hidden md:table-cell text-sm text-zinc-400">
|
|
{new Date(order.orderDate).toLocaleDateString("en-GB", {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
})}
|
|
<span className="ml-1 opacity-50 text-[10px]">
|
|
{new Date(order.orderDate).toLocaleTimeString("en-GB", { hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="hidden xl:table-cell text-sm text-zinc-400">
|
|
{order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
}) : "-"}
|
|
</TableCell>
|
|
<TableCell className="hidden lg:table-cell">
|
|
{order.telegramUsername ? (
|
|
<span className="text-sm font-medium text-indigo-400 hover:text-indigo-300 transition-colors cursor-pointer">@{order.telegramUsername}</span>
|
|
) : (
|
|
<span className="text-xs text-zinc-500 italic">Guest</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-white hover:bg-white/10" asChild>
|
|
<Link href={`/dashboard/orders/${order._id}`}>
|
|
<Eye className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
|
|
{(order.telegramBuyerId || order.telegramUsername) && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-zinc-400 hover:text-indigo-400 hover:bg-indigo-500/10"
|
|
asChild
|
|
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
|
|
>
|
|
<Link href={`/dashboard/chats/new?buyerId=${order.telegramBuyerId || order.telegramUsername}`}>
|
|
<MessageCircle className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</motion.tr>
|
|
);
|
|
})}
|
|
</AnimatePresence>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
|
|
{/* Pagination */}
|
|
<div className="flex items-center justify-between px-4 py-4 border-t border-white/5 bg-white/[0.02]">
|
|
<div className="text-sm text-zinc-500">
|
|
Page {currentPage} of {totalPages} ({totalOrders} total)
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
disabled={currentPage === 1 || loading}
|
|
className="h-8 bg-transparent border-white/10 hover:bg-white/5 text-zinc-400 hover:text-white"
|
|
>
|
|
<ChevronLeft className="h-3 w-3 mr-1" />
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
|
disabled={currentPage >= totalPages || loading}
|
|
className="h-8 bg-transparent border-white/10 hover:bg-white/5 text-zinc-400 hover:text-white"
|
|
>
|
|
Next
|
|
<ChevronRight className="h-3 w-3 ml-1" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
</Card>
|
|
</div >
|
|
);
|
|
} |