Some checks failed
Build Frontend / build (push) Failing after 7s
Introduces a modular dashboard system with draggable, configurable widgets including revenue, low stock, recent customers, and pending chats. Adds a dashboard editor for layout customization, widget visibility, and settings. Refactors dashboard content to use the new widget system and improves UI consistency and interactivity.
818 lines
34 KiB
TypeScript
818 lines
34 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import React from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { useSearchParams } from "next/navigation";
|
|
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 searchParams = useSearchParams();
|
|
const initialStatus = searchParams?.get("status") || "all";
|
|
|
|
const [orders, setOrders] = useState<Order[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [statusFilter, setStatusFilter] = useState(initialStatus);
|
|
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
|
|
// Browser detection
|
|
const [isFirefox, setIsFirefox] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const ua = navigator.userAgent.toLowerCase();
|
|
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
|
|
}, []);
|
|
|
|
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;
|
|
}
|
|
|
|
const orderIdsToShip = Array.from(selectedOrders);
|
|
|
|
// Store previous state for rollback
|
|
const previousOrders = [...orders];
|
|
|
|
// Optimistic update - immediately mark orders as shipped in UI
|
|
setOrders(prev =>
|
|
prev.map(order =>
|
|
selectedOrders.has(order._id)
|
|
? { ...order, status: "shipped" as const }
|
|
: order
|
|
)
|
|
);
|
|
setSelectedOrders(new Set());
|
|
|
|
// Show optimistic toast
|
|
toast.success(`Marking ${orderIdsToShip.length} order(s) as shipped...`, { id: "shipping-optimistic" });
|
|
|
|
try {
|
|
setIsShipping(true);
|
|
const response = await clientFetch("/orders/mark-shipped", {
|
|
method: "POST",
|
|
body: JSON.stringify({ orderIds: orderIdsToShip })
|
|
});
|
|
|
|
// Handle partial success/failure
|
|
if (response.success && response.success.orders) {
|
|
const successfulOrderIds = new Set(response.success.orders.map((o: any) => o.id));
|
|
|
|
// If some orders failed, revert those specifically
|
|
if (response.failed && response.failed.count > 0) {
|
|
setOrders(prev =>
|
|
prev.map(order => {
|
|
if (orderIdsToShip.includes(order._id) && !successfulOrderIds.has(order._id)) {
|
|
// Find original status from previousOrders
|
|
const originalOrder = previousOrders.find(o => o._id === order._id);
|
|
return originalOrder || order;
|
|
}
|
|
return order;
|
|
})
|
|
);
|
|
toast.warning(`${response.failed.count} order(s) could not be marked as shipped`, { id: "shipping-optimistic" });
|
|
} else if (response.success.count > 0) {
|
|
toast.success(`${response.success.count} order(s) marked as shipped!`, { id: "shipping-optimistic" });
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Revert all changes on error
|
|
setOrders(previousOrders);
|
|
toast.error("Failed to update orders - changes reverted", { id: "shipping-optimistic" });
|
|
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-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
|
{/* Filters header */}
|
|
<div className="p-4 border-b border-border/50 bg-muted/30">
|
|
<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-background/50 border-border/50 hover:bg-muted/50 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-md">
|
|
<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-muted/30 sticky top-0 z-20">
|
|
<TableRow className="hover:bg-transparent border-border/50">
|
|
<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-muted/50 border-b border-border/50 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-primary">@{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, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2, delay: index * 0.03 }}
|
|
className="group hover:bg-muted/50 border-b border-border/50 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-primary">@{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 >
|
|
);
|
|
} |