update item nav

This commit is contained in:
Admin 2025-11-13 13:59:14 +07:00
parent 09b14bc8ac
commit 3c418952d5
10 changed files with 213 additions and 135 deletions

32
package-lock.json generated
View File

@ -30,7 +30,8 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"typeorm": "^0.3.27",
"zod": "^4.1.12"
"zod": "^4.1.12",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@ -8391,6 +8392,35 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@ -31,7 +31,8 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"typeorm": "^0.3.27",
"zod": "^4.1.12"
"zod": "^4.1.12",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

View File

@ -1,18 +1,6 @@
import DataSession from "@/components/home/data-session";
import HomePagination from "@/components/home/home-pagination";
import Item from "@/components/home/item";
import SKUListSidebar from "@/components/home/sku-list-sidebar";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Sku } from "@/entities/Sku";
import axios from "@/lib/axios";
import { Box } from "lucide-react";
import { use } from "react";
const getData = async (query: { page: number; search: string }) => {
@ -39,41 +27,7 @@ export default function Home({
return (
<div className="grid grid-cols-12 min-h-[1000px] px-5 gap-4 py-2 pb-20">
<div className="col-span-8 display flex flex-col gap-10">
{data?.data?.length > 0 &&
(data.data || []).map(
(
item: Pick<
Sku,
| "id"
| "normalized_title"
| "normalized_short_description"
| "sku"
| "normalized_html"
| "status"
>,
index: number
) => {
return <Item key={item?.sku || index} data={item} />;
}
)}
{data?.data?.length <= 0 && (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Box />
</EmptyMedia>
<EmptyTitle>No Products</EmptyTitle>
</EmptyHeader>
</Empty>
)}
</div>
<div className="col-span-4 -my-2 border-l">
<div className="sticky z-50 top-0">
<SKUListSidebar data={data?.data} />
</div>
</div>
<DataSession data={data} />
<HomePagination page={page} totalPages={data.pagination.totalPages} />
</div>

View File

@ -0,0 +1,38 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Box } from "lucide-react";
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
import Item from "./item";
import SKUListSidebar from "./sku-list-sidebar";
export interface IDataSessionProps {
data: any;
}
export default function DataSession({ data }: IDataSessionProps) {
return (
<>
<div className="col-span-8 display flex flex-col gap-10">
{data?.data?.length > 0 &&
(data.data || []).map((item: ItemType, index: number) => {
return <Item key={item?.sku || index} data={item} />;
})}
{data?.data?.length <= 0 && (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Box />
</EmptyMedia>
<EmptyTitle>No Products</EmptyTitle>
</EmptyHeader>
</Empty>
)}
</div>
<div className="col-span-4 -my-2 border-l">
<div className="sticky z-50 top-0">
<SKUListSidebar data={data?.data} />
</div>
</div>
</>
);
}

View File

@ -23,17 +23,10 @@ import { Input } from "../ui/input";
import { Spinner } from "../ui/spinner";
import { Textarea } from "../ui/textarea";
import UncontrolledJoditEditor from "./uncontrolled-jodit-editor";
import { useItemStore } from "@/stores/item-store";
export interface IItemProps {
data: Pick<
Sku,
| "id"
| "normalized_title"
| "normalized_short_description"
| "normalized_html"
| "sku"
| "status"
>;
data: ItemType;
}
const ItemSchema = z.object({
@ -50,6 +43,8 @@ type ItemFormValues = z.infer<typeof ItemSchema>;
export default function Item({ data }: IItemProps) {
const [loading, setLoading] = useState(false);
const { markUpdated } = useItemStore();
const form = useForm<ItemFormValues>({
resolver: zodResolver(ItemSchema),
defaultValues: {
@ -65,6 +60,7 @@ export default function Item({ data }: IItemProps) {
const res = await axios.get(`skus/${data.id}`);
if (res?.data) {
form.reset(res.data);
markUpdated(res.data);
}
} catch (error) {
console.log(error);

View File

@ -0,0 +1,76 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useItemStore } from "@/stores/item-store";
import { ChevronRight } from "lucide-react";
import { useMemo } from "react";
export interface INavItemProps {
item: ItemType;
active?: boolean;
onClick?: (data: ItemType) => void;
}
export default function NavItem({ item, active, onClick }: INavItemProps) {
const { updatedItems } = useItemStore();
const dyData = useMemo(() => {
const i = updatedItems.find((j) => j.id === item.id);
if (!i) return item;
return i;
}, [updatedItems, item]);
return (
<div
onClick={(e) => {
e.preventDefault(); // chặn default jump
// setSelectedSKU(item.sku);
onClick?.(dyData);
const el = document.getElementById(dyData.sku);
if (el) {
el.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}}
className={`group flex items-start gap-3 px-3 py-3 rounded-lg transition-colors cursor-pointer ${
// selectedSKU === item.sku
active
? "bg-primary/10 border border-primary/20"
: "hover:bg-muted border border-transparent"
}`}
>
{/* SKU Badge */}
<div className="inline-flex items-center justify-center px-3 py-1 rounded-md bg-primary/20">
<span className="text-xs font-semibold text-primary truncate">
{dyData.sku}
</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground truncate">
{dyData.normalized_title}
</p>
<span
className={`text-xs px-2 py-1 rounded-full whitespace-nowrap capitalize ${
dyData.status === "pass"
? "bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-400"
: "bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-400"
}`}
>
{dyData.status}
</span>
</div>
<p className="text-xs text-muted-foreground truncate mt-1">
{dyData.normalized_short_description}
</p>
</div>
{/* Chevron */}
<ChevronRight className="w-4 h-4 text-muted-foreground group-hover:text-foreground mt-1 transition-colors" />
</div>
);
}

View File

@ -1,24 +1,13 @@
"use client";
import { Input } from "@/components/ui/input";
import { Sku } from "@/entities/Sku";
import { ChevronRight, Search, X } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { Spinner } from "../ui/spinner";
import NavItem from "./nav-item";
export default function SKUListSidebar({
data,
}: {
data: Pick<
Sku,
| "id"
| "normalized_title"
| "normalized_short_description"
| "sku"
| "status"
>[];
}) {
export default function SKUListSidebar({ data }: { data: ItemType[] }) {
const searchParams = useSearchParams();
const [searchQuery, setSearchQuery] = useState("");
const [selectedSKU, setSelectedSKU] = useState<string | null>(null);
@ -78,56 +67,12 @@ export default function SKUListSidebar({
<nav className="p-2 space-y-1">
{data.length > 0 ? (
data.map((item) => (
<div
<NavItem
key={item.sku}
onClick={(e) => {
e.preventDefault(); // chặn default jump
setSelectedSKU(item.sku);
const el = document.getElementById(item.sku);
if (el) {
el.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}}
className={`group flex items-start gap-3 px-3 py-3 rounded-lg transition-colors cursor-pointer ${
selectedSKU === item.sku
? "bg-primary/10 border border-primary/20"
: "hover:bg-muted border border-transparent"
}`}
>
{/* SKU Badge */}
<div className="inline-flex items-center justify-center px-3 py-1 rounded-md bg-primary/20">
<span className="text-xs font-semibold text-primary truncate">
{item.sku}
</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground truncate">
{item.normalized_title}
</p>
<span
className={`text-xs px-2 py-1 rounded-full whitespace-nowrap capitalize ${
item.status === "pass"
? "bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-400"
: "bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-400"
}`}
>
{item.status}
</span>
</div>
<p className="text-xs text-muted-foreground truncate mt-1">
{item.normalized_short_description}
</p>
</div>
{/* Chevron */}
<ChevronRight className="w-4 h-4 text-muted-foreground group-hover:text-foreground mt-1 transition-colors" />
</div>
active={selectedSKU === item.sku}
item={item}
onClick={(i) => setSelectedSKU(i.sku)}
/>
))
) : (
<div className="px-3 py-8 text-center">

View File

@ -1,12 +1,12 @@
import * as React from "react"
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
} from "lucide-react";
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
@ -17,7 +17,7 @@ function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
);
}
function PaginationContent({
@ -30,17 +30,17 @@ function PaginationContent({
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
React.ComponentProps<"a">;
function PaginationLink({
className,
@ -62,7 +62,7 @@ function PaginationLink({
)}
{...props}
/>
)
);
}
function PaginationPrevious({
@ -79,7 +79,7 @@ function PaginationPrevious({
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
);
}
function PaginationNext({
@ -96,7 +96,7 @@ function PaginationNext({
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
);
}
function PaginationEllipsis({
@ -113,7 +113,7 @@ function PaginationEllipsis({
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
);
}
export {
@ -124,4 +124,4 @@ export {
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}
};

29
src/stores/item-store.ts Normal file
View File

@ -0,0 +1,29 @@
import { create } from "zustand";
interface ItemStore {
updatedItems: ItemType[];
markUpdated: (item: ItemType) => void;
clearUpdated: () => void;
removeItem: (id: string | number) => void;
}
export const useItemStore = create<ItemStore>((set) => ({
updatedItems: [],
markUpdated: (item: ItemType) =>
set((state) => {
const existIndex = state.updatedItems.findIndex((i) => i.id === item.id);
if (existIndex > -1) {
// update item hiện tại
const updatedItems = [...state.updatedItems];
updatedItems[existIndex] = item;
return { updatedItems };
}
// nếu chưa có thì thêm mới
return { updatedItems: [...state.updatedItems, item] };
}),
clearUpdated: () => set({ updatedItems: [] }),
removeItem: (id: string | number) =>
set((state) => ({
updatedItems: state.updatedItems.filter((i) => i.id !== id),
})),
}));

9
src/type.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
type ItemType = Pick<
Sku,
| "id"
| "normalized_title"
| "normalized_short_description"
| "sku"
| "normalized_html"
| "status"
>;