420 lines
15 KiB
TypeScript
420 lines
15 KiB
TypeScript
import { productApi } from "@/api/product-api.service";
|
|
import { ImprovedToggleFilter } from "@/components/improved-toggle-filter";
|
|
import Loader from "@/components/loader";
|
|
import ProductModal from "@/components/product-modal";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { removeFalsyValues } from "@/features/app";
|
|
import { mapToIPost, type IPost } from "@/lib/utils";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
DoorOpenIcon,
|
|
Edit,
|
|
Eye,
|
|
EyeOff,
|
|
MoreHorizontal,
|
|
RefreshCcwIcon,
|
|
Search,
|
|
} from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
import { useDebounce } from "use-debounce";
|
|
|
|
export default function Popup() {
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [filter, setFilter] = useState<Record<string, any>>({});
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
const [search] = useDebounce(searchTerm, 400);
|
|
|
|
const queryKey = useMemo(
|
|
() => ["products", { currentPage, search, filter }],
|
|
[currentPage, search, filter]
|
|
);
|
|
|
|
// --- React Query fetch ---
|
|
const {
|
|
data: rawProducts,
|
|
isFetching,
|
|
...dataQuery
|
|
} = useQuery({
|
|
queryKey,
|
|
queryFn: async () => {
|
|
const data = await productApi.apiRequest(
|
|
"index",
|
|
removeFalsyValues({
|
|
skip: (currentPage - 1) * productApi.item_per_page,
|
|
where: {
|
|
productModelCode: searchTerm,
|
|
status_listing:
|
|
filter?.statusFilter === "all" ? undefined : filter.statusFilter,
|
|
},
|
|
})
|
|
);
|
|
return data;
|
|
},
|
|
});
|
|
|
|
const { data: publistedProducts, ...publistQuery } = useQuery({
|
|
queryKey: ["publised-products"],
|
|
queryFn: async () => {
|
|
const data = await productApi.apiRequest("getPublistedProducts", {});
|
|
return data ?? [];
|
|
},
|
|
staleTime: 0, // luôn coi là stale -> gọi lại API mỗi lần mount
|
|
refetchOnMount: "always",
|
|
});
|
|
|
|
const actionMutation = useMutation({
|
|
mutationKey: ["action-mutaions"],
|
|
mutationFn: async (data: IPost) => {
|
|
if (data.status) {
|
|
return productApi.apiRequest("unlist", data);
|
|
}
|
|
|
|
const res = await productApi.apiRequest("get", data);
|
|
|
|
if (!res || !(res as any)?.data) return;
|
|
|
|
return productApi.apiRequest("publist", {
|
|
...data,
|
|
images: mapToIPost({ ...(res as any)?.data }).images,
|
|
});
|
|
},
|
|
});
|
|
|
|
const data: IPost[] = useMemo(() => {
|
|
if (!rawProducts || !(rawProducts as any)?.data) return [];
|
|
return (rawProducts as any)?.data.map((item: any) => mapToIPost(item));
|
|
}, [rawProducts]);
|
|
|
|
const formatPrice = (price: number) =>
|
|
new Intl.NumberFormat("en-US", {
|
|
style: "currency",
|
|
currency: "USD",
|
|
}).format(price);
|
|
|
|
const clearFilters = () => {
|
|
setSearchTerm("");
|
|
setFilter({ statusFilter: "" });
|
|
setCurrentPage(1);
|
|
};
|
|
|
|
const activeFiltersCount = [filter.statusFilter, searchTerm !== ""].filter(
|
|
Boolean
|
|
).length;
|
|
|
|
const totalPages = useMemo(() => {
|
|
if (!(rawProducts as any)?.total) return 0;
|
|
|
|
return Math.ceil((rawProducts as any).total / productApi.item_per_page);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [(rawProducts as any)?.total, productApi.item_per_page]);
|
|
|
|
// --- reset page when filter changes ---
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
const from = useMemo(() => {
|
|
return (currentPage - 1) * productApi.item_per_page + 1;
|
|
}, [currentPage]);
|
|
|
|
const to = useMemo(() => {
|
|
return Math.min(
|
|
currentPage * productApi.item_per_page,
|
|
(rawProducts as any)?.total ?? 0
|
|
);
|
|
}, [currentPage, rawProducts]);
|
|
|
|
const handleActionListing = async (data: IPost) => {
|
|
console.log({ post: data });
|
|
actionMutation.mutate(data);
|
|
};
|
|
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button size={"icon"}>
|
|
<DoorOpenIcon />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent side="top" align="end" className="w-auto">
|
|
<div className="space-y-4 min-h-[722px] min-w-[638px]">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
|
|
<ImprovedToggleFilter
|
|
filter={filter as any}
|
|
setFilter={setFilter}
|
|
activeFiltersCount={activeFiltersCount}
|
|
clearFilters={clearFilters}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between w-full gap-8">
|
|
<span>
|
|
Showing {from}-{to} of {(rawProducts as any)?.total ?? 0} products
|
|
</span>
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
onClick={() =>
|
|
setCurrentPage((prev) => Math.max(1, prev - 1))
|
|
}
|
|
disabled={currentPage === 1}
|
|
>
|
|
<ChevronLeft />
|
|
Previous
|
|
</Button>
|
|
|
|
<div className="flex items-center gap-1">
|
|
{totalPages <= 7 ? (
|
|
// Show all pages if 7 or fewer
|
|
Array.from({ length: totalPages }, (_, i) => (
|
|
<Button
|
|
key={i + 1}
|
|
variant={currentPage === i + 1 ? "default" : "outline"}
|
|
size="xs"
|
|
onClick={() => setCurrentPage(i + 1)}
|
|
>
|
|
{i + 1}
|
|
</Button>
|
|
))
|
|
) : (
|
|
// Show pages with ellipsis for more than 7 pages
|
|
<>
|
|
{/* First page */}
|
|
<Button
|
|
variant={currentPage === 1 ? "default" : "outline"}
|
|
size="xs"
|
|
onClick={() => setCurrentPage(1)}
|
|
>
|
|
1
|
|
</Button>
|
|
|
|
{/* Left ellipsis */}
|
|
{currentPage > 4 && (
|
|
<span className="px-2 text-muted-foreground">...</span>
|
|
)}
|
|
|
|
{/* Middle pages */}
|
|
{Array.from({ length: 3 }, (_, i) => {
|
|
let pageNum;
|
|
if (currentPage <= 4) {
|
|
pageNum = i + 2;
|
|
} else if (currentPage >= totalPages - 3) {
|
|
pageNum = totalPages - 4 + i;
|
|
} else {
|
|
pageNum = currentPage - 1 + i;
|
|
}
|
|
|
|
if (pageNum > 1 && pageNum < totalPages) {
|
|
return (
|
|
<Button
|
|
key={pageNum}
|
|
variant={
|
|
currentPage === pageNum ? "default" : "outline"
|
|
}
|
|
size="xs"
|
|
onClick={() => setCurrentPage(pageNum)}
|
|
>
|
|
{pageNum}
|
|
</Button>
|
|
);
|
|
}
|
|
return null;
|
|
}).filter(Boolean)}
|
|
|
|
{/* Right ellipsis */}
|
|
{currentPage < totalPages - 3 && (
|
|
<span className="px-2 text-muted-foreground">...</span>
|
|
)}
|
|
|
|
{/* Last page */}
|
|
<Button
|
|
variant={
|
|
currentPage === totalPages ? "default" : "outline"
|
|
}
|
|
size="xs"
|
|
onClick={() => setCurrentPage(totalPages)}
|
|
>
|
|
{totalPages}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
onClick={() =>
|
|
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
|
|
}
|
|
disabled={currentPage === totalPages}
|
|
>
|
|
Next
|
|
<ChevronRight />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="rounded-md border overflow-x-auto relative">
|
|
<Table className="min-w-[500px]">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="min-w-[200px]">Name</TableHead>
|
|
<TableHead className="w-[80px]">Price</TableHead>
|
|
<TableHead className="w-[80px]">Status</TableHead>
|
|
<TableHead className="w-[80px] text-center sticky right-0 bg-background border-l shadow-[-4px_0_8px_rgba(0,0,0,0.1)]">
|
|
Actions
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
|
|
<TableBody className="relative">
|
|
{isFetching && (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={10}
|
|
className="text-center py-8 text-muted-foreground "
|
|
>
|
|
<div className="h-full flex items-center justify-center w-full">
|
|
<Loader />
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
|
|
{data.length === 0 && !isFetching ? (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={10}
|
|
className="text-center py-8 text-muted-foreground"
|
|
>
|
|
<div className="h-full flex items-center justify-center w-full">
|
|
<span> No products found</span>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
data.map((post) => {
|
|
const status = (publistedProducts as any)?.some(
|
|
(item: any) => item.title.includes(post.sku)
|
|
);
|
|
|
|
post.status = status;
|
|
|
|
return (
|
|
<TableRow key={post.id}>
|
|
<TableCell className="font-medium">
|
|
<div
|
|
className="truncate max-w-[340px] w-fit"
|
|
title={post.title}
|
|
>
|
|
{post.title}
|
|
</div>
|
|
<div
|
|
className="text-sm text-muted-foreground truncate max-w-[340px]"
|
|
title={post.description}
|
|
>
|
|
{post.description}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="font-semibold">
|
|
{formatPrice(post.price)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="secondary">
|
|
{post?.status ? "Listed" : "Unlisted"}
|
|
</Badge>
|
|
</TableCell>
|
|
|
|
<TableCell className="text-center sticky right-0 bg-background border-l shadow-[-4px_0_8px_rgba(0,0,0,0.1)]">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<div className="w-full flex items-center justify-center">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0 mx-auto"
|
|
>
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<ProductModal data={post}>
|
|
<DropdownMenuItem
|
|
onSelect={(e) => {
|
|
e.preventDefault(); // Ngăn dropdown đóng lại
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
<Edit className="h-4 w-4 mr-2" /> Edit
|
|
</DropdownMenuItem>
|
|
</ProductModal>
|
|
<DropdownMenuItem
|
|
onClick={() => handleActionListing(post)}
|
|
>
|
|
{post.status ? (
|
|
<EyeOff className="h-4 w-4 mr-2" />
|
|
) : (
|
|
<Eye className="h-4 w-4 mr-2" />
|
|
)}
|
|
{post.status ? "Unlist" : "List"}
|
|
|
|
{/* {actionMutation.isPending && <Loader />} */}
|
|
</DropdownMenuItem>
|
|
{/* <DropdownMenuItem onClick={() => handleUnListing(post)}>
|
|
unList
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleRePublist(post)}>
|
|
re publist
|
|
</DropdownMenuItem> */}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|