Files
ember-market-frontend/components/tables/order-table.tsx
NotII f1e7583219 Add paidAt field and display to order details and table
Introduces the optional 'paidAt' field to the Order interface and updates both the order details page and the order table to display the payment date. Also adds sorting by 'paidAt' in the order table and improves date formatting for both order and payment dates.
2025-07-26 22:32:47 +02:00

571 lines
21 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import React from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Eye,
Loader2,
CheckCircle2,
XCircle,
ChevronLeft,
ChevronRight,
ArrowUpDown,
Truck,
MessageCircle,
AlertTriangle
} from "lucide-react";
import Link from "next/link";
import { clientFetch } from '@/lib/api';
import { toast } from "sonner";
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;
}
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);
// 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
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-white",
bgColor: "bg-purple-600"
},
paid: {
icon: CheckCircle2,
color: "text-white",
bgColor: "bg-emerald-600"
},
unpaid: {
icon: XCircle,
color: "text-white",
bgColor: "bg-red-500"
},
confirming: {
icon: Loader2,
color: "text-white",
bgColor: "bg-yellow-500",
animate: "animate-spin"
},
shipped: {
icon: Truck,
color: "text-white",
bgColor: "bg-blue-600"
},
completed: {
icon: CheckCircle2,
color: "text-white",
bgColor: "bg-green-600"
},
cancelled: {
icon: XCircle,
color: "text-white",
bgColor: "bg-gray-500"
}
};
// 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");
};
// 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">
<div className="border border-zinc-800 rounded-md bg-black/40 overflow-hidden">
{/* Filters header */}
<div className="p-4 border-b border-zinc-800 bg-black/60">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
<StatusFilter
currentStatus={statusFilter}
onChange={setStatusFilter}
/>
<PageSizeSelector
currentSize={itemsPerPage}
onChange={(value) => handleItemsPerPageChange({ target: { value } } as React.ChangeEvent<HTMLSelectElement>)}
options={pageSizeOptions}
/>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button disabled={selectedOrders.size === 0 || isShipping}>
<Truck className="mr-2 h-5 w-5" />
{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 */}
<div className="relative">
{loading && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
<div className="max-h-[calc(100vh-300px)] overflow-auto">
<Table className="[&_tr]:border-b [&_tr]:border-zinc-800 [&_tr:last-child]:border-b-0 [&_td]:border-r [&_td]:border-zinc-800 [&_td:last-child]:border-r-0 [&_th]:border-r [&_th]:border-zinc-800 [&_th:last-child]:border-r-0 [&_tr:hover]:bg-zinc-900/70">
<TableHeader className="bg-black/60 sticky top-0 z-10">
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
onCheckedChange={toggleAll}
/>
</TableHead>
<TableHead className="cursor-pointer" onClick={() => handleSort("orderId")}>
Order ID <ArrowUpDown className="ml-2 inline h-4 w-4" />
</TableHead>
<TableHead className="cursor-pointer" onClick={() => handleSort("totalPrice")}>
Total <ArrowUpDown className="ml-2 inline h-4 w-4" />
</TableHead>
<TableHead className="cursor-pointer" onClick={() => handleSort("status")}>
Status <ArrowUpDown className="ml-2 inline h-4 w-4" />
</TableHead>
<TableHead className="cursor-pointer" onClick={() => handleSort("orderDate")}>
Order Date <ArrowUpDown className="ml-2 inline h-4 w-4" />
</TableHead>
<TableHead className="cursor-pointer" onClick={() => handleSort("paidAt")}>
Paid At <ArrowUpDown className="ml-2 inline h-4 w-4" />
</TableHead>
<TableHead>Buyer</TableHead>
<TableHead className="w-24 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedOrders.map((order) => {
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
const underpaidInfo = getUnderpaidInfo(order);
return (
<TableRow key={order._id}>
<TableCell>
<Checkbox
checked={selectedOrders.has(order._id)}
onCheckedChange={() => toggleSelection(order._id)}
disabled={order.status !== "paid" && order.status !== "acknowledged"}
/>
</TableCell>
<TableCell>#{order.orderId}</TableCell>
<TableCell>
<div className="flex flex-col">
<span>£{order.totalPrice.toFixed(2)}</span>
{underpaidInfo && (
<span className="text-xs text-red-400">
Missing: £{underpaidInfo.missingGbp.toFixed(2)} ({underpaidInfo.missing.toFixed(8)} LTC)
</span>
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm ${
statusConfig[order.status as OrderStatus]?.bgColor || "bg-gray-500"
} ${statusConfig[order.status as OrderStatus]?.color || "text-white"}`}>
{React.createElement(statusConfig[order.status as OrderStatus]?.icon || XCircle, {
className: `h-4 w-4 ${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-xs bg-red-600 text-white">
<AlertTriangle className="h-3 w-3" />
{underpaidInfo?.percentage}%
</div>
)}
</div>
</TableCell>
<TableCell>
{new Date(order.orderDate).toLocaleDateString("en-GB", {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
})}
</TableCell>
<TableCell>
{order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
}) : "-"}
</TableCell>
<TableCell>
{order.telegramUsername ? `@${order.telegramUsername}` : "-"}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="sm" asChild>
<Link href={`/dashboard/orders/${order._id}`}>
<Eye className="h-4 w-4" />
</Link>
</Button>
{(order.telegramBuyerId || order.telegramUsername) && (
<Button
variant="ghost"
size="sm"
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 text-primary" />
</Link>
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-4 border-t border-zinc-800 bg-black/40">
<div className="text-sm text-muted-foreground">
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}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || loading}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
);
}