455 lines
19 KiB
TypeScript
455 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState, useCallback } from "react";
|
|
import { getCustomers, CustomerStats } from "@/services/customerService";
|
|
import { formatCurrency } from "@/utils/format";
|
|
import { useRouter } from "next/navigation";
|
|
import { toast } from "sonner";
|
|
import Layout from "@/components/layout/layout";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Loader2,
|
|
Users,
|
|
ArrowUpDown,
|
|
MessageCircle,
|
|
UserPlus,
|
|
MoreHorizontal
|
|
} from "lucide-react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
} from "@/components/ui/dropdown-menu";
|
|
|
|
export default function CustomerManagementPage() {
|
|
const router = useRouter();
|
|
const [customers, setCustomers] = useState<CustomerStats[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [page, setPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [itemsPerPage, setItemsPerPage] = useState(25);
|
|
const [selectedCustomer, setSelectedCustomer] = useState<CustomerStats | null>(null);
|
|
const [sortConfig, setSortConfig] = useState<{
|
|
column: "totalOrders" | "totalSpent" | "lastOrderDate";
|
|
direction: "asc" | "desc";
|
|
}>({ column: "totalSpent", direction: "desc" });
|
|
|
|
const fetchCustomers = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await getCustomers(page, itemsPerPage);
|
|
|
|
// Sort customers based on current sort config
|
|
let sortedCustomers = [...response.customers];
|
|
sortedCustomers.sort((a, b) => {
|
|
if (sortConfig.column === "totalOrders") {
|
|
return sortConfig.direction === "asc"
|
|
? a.totalOrders - b.totalOrders
|
|
: b.totalOrders - a.totalOrders;
|
|
} else if (sortConfig.column === "totalSpent") {
|
|
return sortConfig.direction === "asc"
|
|
? a.totalSpent - b.totalSpent
|
|
: b.totalSpent - a.totalSpent;
|
|
} else if (sortConfig.column === "lastOrderDate") {
|
|
// Handle null lastOrderDate values
|
|
if (!a.lastOrderDate && !b.lastOrderDate) return 0;
|
|
if (!a.lastOrderDate) return sortConfig.direction === "asc" ? -1 : 1;
|
|
if (!b.lastOrderDate) return sortConfig.direction === "asc" ? 1 : -1;
|
|
|
|
// Both have valid dates
|
|
return sortConfig.direction === "asc"
|
|
? new Date(a.lastOrderDate).getTime() - new Date(b.lastOrderDate).getTime()
|
|
: new Date(b.lastOrderDate).getTime() - new Date(a.lastOrderDate).getTime();
|
|
}
|
|
return 0;
|
|
});
|
|
|
|
setCustomers(sortedCustomers);
|
|
setTotalPages(Math.ceil(response.total / itemsPerPage));
|
|
} catch (err) {
|
|
toast.error("Failed to fetch customers");
|
|
console.error(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [page, itemsPerPage, sortConfig]);
|
|
|
|
useEffect(() => {
|
|
fetchCustomers();
|
|
}, [fetchCustomers]);
|
|
|
|
useEffect(() => {
|
|
const authToken = document.cookie
|
|
.split("; ")
|
|
.find((row) => row.startsWith("Authorization="))
|
|
?.split("=")[1];
|
|
|
|
if (!authToken) {
|
|
router.push("/login");
|
|
}
|
|
}, [router]);
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
setPage(newPage);
|
|
};
|
|
|
|
const handleItemsPerPageChange = (value: string) => {
|
|
setItemsPerPage(parseInt(value, 10));
|
|
setPage(1); // Reset to first page when changing items per page
|
|
};
|
|
|
|
const handleSort = (column: "totalOrders" | "totalSpent" | "lastOrderDate") => {
|
|
setSortConfig(prev => ({
|
|
column,
|
|
direction: prev.column === column && prev.direction === "asc" ? "desc" : "asc"
|
|
}));
|
|
};
|
|
|
|
// Format date with time
|
|
const formatDate = (dateString: string | null | undefined) => {
|
|
if (!dateString) return "N/A";
|
|
try {
|
|
const date = new Date(dateString);
|
|
return new Intl.DateTimeFormat('en-GB', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
}).format(date);
|
|
} catch (error) {
|
|
return "N/A";
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-semibold text-white flex items-center">
|
|
<Users className="mr-2 h-6 w-6" />
|
|
Customer Management
|
|
</h1>
|
|
</div>
|
|
|
|
<div className="bg-black/40 border border-zinc-800 rounded-md overflow-hidden">
|
|
<div className="p-4 border-b border-zinc-800 bg-black/60 flex justify-between items-center">
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="text-sm font-medium text-gray-400">Show:</div>
|
|
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
|
|
<SelectTrigger className="w-[70px]">
|
|
<SelectValue placeholder="25" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{[5, 10, 25, 50, 100].map(size => (
|
|
<SelectItem key={size} value={size.toString()}>{size}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-sm text-gray-400">
|
|
{loading
|
|
? "Loading..."
|
|
: `Showing ${customers.length} of ${totalPages * itemsPerPage} customers`}
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="p-8 flex justify-center bg-black/60">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
</div>
|
|
) : customers.length === 0 ? (
|
|
<div className="p-8 text-center bg-black/60">
|
|
<Users className="h-12 w-12 mx-auto text-gray-500 mb-4" />
|
|
<h3 className="text-lg font-medium mb-2 text-white">No customers found</h3>
|
|
<p className="text-gray-500">
|
|
Once you have customers placing orders, they will appear here.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-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-[250px] text-gray-300">Customer</TableHead>
|
|
<TableHead
|
|
className="cursor-pointer w-[100px] text-gray-300"
|
|
onClick={() => handleSort("totalOrders")}
|
|
>
|
|
<div className="flex items-center">
|
|
Orders
|
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
</div>
|
|
</TableHead>
|
|
<TableHead
|
|
className="cursor-pointer w-[150px] text-gray-300"
|
|
onClick={() => handleSort("totalSpent")}
|
|
>
|
|
<div className="flex items-center">
|
|
Total Spent
|
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
</div>
|
|
</TableHead>
|
|
<TableHead
|
|
className="cursor-pointer w-[180px] text-gray-300"
|
|
onClick={() => handleSort("lastOrderDate")}
|
|
>
|
|
<div className="flex items-center">
|
|
Last Order
|
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
</div>
|
|
</TableHead>
|
|
<TableHead className="w-[250px] text-gray-300">Status</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{customers.map((customer) => (
|
|
<TableRow
|
|
key={customer.userId}
|
|
className={`cursor-pointer ${!customer.hasOrders ? "bg-black/30" : ""}`}
|
|
onClick={() => setSelectedCustomer(customer)}
|
|
>
|
|
<TableCell>
|
|
<div className="font-medium text-gray-100">
|
|
@{customer.telegramUsername || "Unknown"}
|
|
{!customer.hasOrders && (
|
|
<Badge variant="outline" className="ml-2 bg-purple-900/30 text-purple-300 border-purple-700">
|
|
<UserPlus className="h-3 w-3 mr-1" />
|
|
New
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="text-sm text-gray-400">ID: {customer.telegramUserId}</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge className="bg-gray-700 text-white hover:bg-gray-600">{customer.totalOrders}</Badge>
|
|
</TableCell>
|
|
<TableCell className="font-medium text-gray-100">
|
|
{formatCurrency(customer.totalSpent)}
|
|
</TableCell>
|
|
<TableCell className="text-sm text-gray-100">
|
|
{customer.lastOrderDate ? formatDate(customer.lastOrderDate) : "N/A"}
|
|
</TableCell>
|
|
<TableCell>
|
|
{customer.hasOrders ? (
|
|
<div className="flex space-x-1">
|
|
<Badge className="bg-blue-500 text-white hover:bg-blue-600">
|
|
{customer.ordersByStatus.paid} Paid
|
|
</Badge>
|
|
<Badge className="bg-green-500 text-white hover:bg-green-600">
|
|
{customer.ordersByStatus.completed} Completed
|
|
</Badge>
|
|
<Badge className="bg-amber-500 text-white hover:bg-amber-600">
|
|
{customer.ordersByStatus.shipped} Shipped
|
|
</Badge>
|
|
</div>
|
|
) : (
|
|
<Badge variant="outline" className="bg-gray-800 text-gray-300 border-gray-700">
|
|
No orders yet
|
|
</Badge>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
|
|
<div className="p-4 border-t border-zinc-800 bg-black/40 flex justify-between items-center">
|
|
<div className="text-sm text-gray-400">
|
|
Page {page} of {totalPages}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(Math.max(1, page - 1))}
|
|
disabled={page === 1 || loading}
|
|
>
|
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
|
Previous
|
|
</Button>
|
|
|
|
{totalPages > 2 && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm">
|
|
<span className="sr-only">Go to page</span>
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="center" className="bg-black/90 border-zinc-800 max-h-60 overflow-y-auto">
|
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => (
|
|
<DropdownMenuItem
|
|
key={pageNum}
|
|
onClick={() => handlePageChange(pageNum)}
|
|
className={`cursor-pointer ${pageNum === page ? 'bg-zinc-800 text-white' : 'text-gray-300'}`}
|
|
>
|
|
Page {pageNum}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
|
|
disabled={page === totalPages || loading}
|
|
>
|
|
Next
|
|
<ChevronRight className="h-4 w-4 ml-1" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Customer Details Dialog */}
|
|
{selectedCustomer && (
|
|
<Dialog open={!!selectedCustomer} onOpenChange={(open) => !open && setSelectedCustomer(null)}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-2xl w-full overflow-y-auto max-h-[90vh] z-[80]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base">
|
|
Customer Details
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 py-4">
|
|
{/* Customer Information */}
|
|
<div>
|
|
<div className="mb-4">
|
|
<h3 className="text-sm font-medium mb-2">Customer Information</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between items-center text-sm">
|
|
<div className="text-muted-foreground">Username:</div>
|
|
<div className="font-medium">@{selectedCustomer.telegramUsername || "Unknown"}</div>
|
|
</div>
|
|
<div className="flex justify-between items-center text-sm">
|
|
<div className="text-muted-foreground">Telegram ID:</div>
|
|
<div className="font-medium">{selectedCustomer.telegramUserId}</div>
|
|
</div>
|
|
<div className="flex justify-between items-center text-sm">
|
|
<div className="text-muted-foreground">Chat ID:</div>
|
|
<div className="font-medium">{selectedCustomer.chatId}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={() => {
|
|
window.open(`https://t.me/${selectedCustomer.telegramUsername || selectedCustomer.telegramUserId}`, '_blank');
|
|
}}
|
|
>
|
|
<MessageCircle className="h-4 w-4 mr-2" />
|
|
Open Telegram Chat
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Order Statistics */}
|
|
<div>
|
|
<h3 className="text-sm font-medium mb-2">Order Statistics</h3>
|
|
<div className="bg-background rounded-lg border border-border p-4 space-y-3">
|
|
<div className="flex justify-between items-center text-sm">
|
|
<div className="text-muted-foreground">Total Orders:</div>
|
|
<div className="font-medium">{selectedCustomer.totalOrders}</div>
|
|
</div>
|
|
<div className="flex justify-between items-center text-sm">
|
|
<div className="text-muted-foreground">Total Spent:</div>
|
|
<div className="font-medium">{formatCurrency(selectedCustomer.totalSpent)}</div>
|
|
</div>
|
|
<div className="flex justify-between items-center text-sm">
|
|
<div className="text-muted-foreground">First Order:</div>
|
|
<div className="font-medium">
|
|
{formatDate(selectedCustomer.firstOrderDate)}
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-between items-center text-sm">
|
|
<div className="text-muted-foreground">Last Order:</div>
|
|
<div className="font-medium">
|
|
{formatDate(selectedCustomer.lastOrderDate)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Order Status Breakdown */}
|
|
<div className="mb-4">
|
|
<h3 className="text-sm font-medium mb-2">Order Status Breakdown</h3>
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
<div className="bg-blue-500/10 rounded-lg border border-blue-500/20 p-3">
|
|
<p className="text-sm text-muted-foreground">Paid</p>
|
|
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.paid}</p>
|
|
</div>
|
|
<div className="bg-purple-500/10 rounded-lg border border-purple-500/20 p-3">
|
|
<p className="text-sm text-muted-foreground">Acknowledged</p>
|
|
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.acknowledged}</p>
|
|
</div>
|
|
<div className="bg-amber-500/10 rounded-lg border border-amber-500/20 p-3">
|
|
<p className="text-sm text-muted-foreground">Shipped</p>
|
|
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.shipped}</p>
|
|
</div>
|
|
<div className="bg-green-500/10 rounded-lg border border-green-500/20 p-3">
|
|
<p className="text-sm text-muted-foreground">Completed</p>
|
|
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.completed}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setSelectedCustomer(null)}
|
|
>
|
|
Close
|
|
</Button>
|
|
<Button
|
|
onClick={() => router.push(`/dashboard/storefront/chat/new?userId=${selectedCustomer.telegramUserId}`)}
|
|
>
|
|
<MessageCircle className="h-4 w-4 mr-2" />
|
|
Start Chat
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|