Manage Product and List Product

This commit is contained in:
Thanh Quy - wolf
2026-04-01 15:06:37 +07:00
parent aa83f7488b
commit e0e64ded9a
9 changed files with 1563 additions and 10 deletions
+5
View File
@@ -0,0 +1,5 @@
Rules:
- Khi làm một tính năng mới, trước khi hoàn thành phải update các file mark down mà thư mục đó được update.
- Khi có từ khóa "Yêu cầu" và một list các yêu cầu thì phải hoàn thành ĐÚNG yêu cầu, không thêm không bớt.
- Sử dụng thư viện tailwind CSS để code css cho project.
- Mỗi feature được update đều phải được responsive với các kích cỡ màn hình như smartphone, tablet, desktop.
+4 -1
View File
@@ -1,5 +1,8 @@
{
"permissions": {
"allow": ["Bash(npx next build)"]
"allow": [
"Bash(npx next build)",
"Bash(npx tsc --noEmit)"
]
}
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useAuth } from "@/lib/auth-context";
import { ManagerProvider } from "@/lib/manager-context";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function ManagerLayout({
children,
}: {
children: React.ReactNode;
}) {
const { user } = useAuth();
const router = useRouter();
// Redirect non-managers away
useEffect(() => {
if (user !== null && user.role !== "manager") {
router.replace("/");
}
}, [user, router]);
// While loading (user is null but not yet checked), show nothing
if (user === null) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4 text-(--color-text-muted)">
<i className="fa-solid fa-spinner fa-spin text-3xl text-(--color-primary)"></i>
<p className="text-sm">Đang kiểm tra quyền truy cập...</p>
<Link
href="/login"
className="text-sm font-medium text-(--color-primary) hover:underline"
>
Đăng nhập nếu chưa tài khoản
</Link>
</div>
</div>
);
}
if (user.role !== "manager") return null;
return (
<ManagerProvider>
<div className="flex min-h-screen flex-col bg-background">
{children}
</div>
</ManagerProvider>
);
}
File diff suppressed because it is too large Load Diff
+17 -9
View File
@@ -79,15 +79,23 @@ export default function Header() {
<span className="hidden sm:inline">Đăng nhập</span>
</button>
) : user.role === "manager" ? (
/* Manager: gold badge */
<button
onClick={handleAuthClick}
title="Nhấn để đăng xuất"
className="flex cursor-pointer items-center gap-2.5 rounded-xl border border-(--color-accent) bg-(--color-accent-light) px-4 py-2.5 text-sm font-semibold text-(--color-primary-dark) transition-all duration-150 hover:bg-(--color-accent) hover:text-white active:scale-95"
>
<i className="fa-solid fa-user-tie text-base"></i>
<span className="hidden sm:inline">Quản </span>
</button>
/* Manager: dashboard link + logout */
<div className="flex items-center gap-2">
<Link
href="/manager"
className="flex items-center gap-2 rounded-xl border border-(--color-accent) bg-(--color-accent-light) px-4 py-2.5 text-sm font-semibold text-(--color-primary-dark) no-underline transition-all duration-150 hover:bg-(--color-accent) hover:text-white"
>
<i className="fa-solid fa-user-tie text-base"></i>
<span className="hidden sm:inline">Dashboard</span>
</Link>
<button
onClick={handleAuthClick}
title="Đăng xuất"
className="flex cursor-pointer items-center gap-2 rounded-xl border border-(--color-border) bg-transparent px-3 py-2.5 text-sm font-medium text-(--color-text-muted) transition-all duration-150 hover:border-red-300 hover:bg-red-50 hover:text-red-500 active:scale-95"
>
<i className="fa-solid fa-right-from-bracket text-base"></i>
</button>
</div>
) : user.role === "staff" ? (
/* Staff: avatar + name */
<button
+41
View File
@@ -1,4 +1,5 @@
import type {
Combo,
MenuCategory,
Product,
Shop,
@@ -232,6 +233,46 @@ export const MOCK_PRODUCTS: Product[] = [
},
];
// ===== MOCK COMBOS =====
export const MOCK_COMBOS: Combo[] = [
{
id: 1,
name: "Combo Cà Phê Đôi",
description: "2 ly cà phê đen + 2 bánh mì nướng bơ, tiết kiệm 15%.",
price: 75000,
image: "/imgs/products/placeholder.jpg",
items: [
{ productId: 1, quantity: 2 },
{ productId: 14, quantity: 2 },
],
available: true,
},
{
id: 2,
name: "Combo Trà Sữa Nhóm",
description: "2 trà đào cam sả + 2 trà xanh matcha, dành cho nhóm bạn.",
price: 130000,
image: "/imgs/products/placeholder.jpg",
items: [
{ productId: 5, quantity: 2 },
{ productId: 6, quantity: 2 },
],
available: true,
},
{
id: 3,
name: "Combo Buổi Sáng",
description: "1 cà phê sữa + 1 bánh flan, khởi đầu ngày mới ngọt ngào.",
price: 48000,
image: "/imgs/products/placeholder.jpg",
items: [
{ productId: 2, quantity: 1 },
{ productId: 15, quantity: 1 },
],
available: false,
},
];
// ===== MOCK SHOPS (for Feed page) =====
export const MOCK_SHOPS: Shop[] = [
{
+173
View File
@@ -0,0 +1,173 @@
"use client";
import {
createContext,
ReactNode,
useContext,
useState,
} from "react";
import {
MENU_CATEGORIES,
MOCK_COMBOS,
MOCK_PRODUCTS,
} from "./constants";
import type { Combo, ComboItem, MenuCategory, Product } from "./types";
// ─── Types ────────────────────────────────────────────────────────────────────
export type ManagerTab = "products" | "combos" | "categories";
interface ManagerContextType {
// Data
products: Product[];
combos: Combo[];
categories: MenuCategory[];
// Active tab
activeTab: ManagerTab;
setActiveTab: (tab: ManagerTab) => void;
// Product actions
addProduct: (product: Omit<Product, "id">) => void;
updateProduct: (product: Product) => void;
deleteProduct: (id: number) => void;
toggleProductAvailability: (id: number) => void;
// Combo actions
addCombo: (combo: Omit<Combo, "id">) => void;
updateCombo: (combo: Combo) => void;
deleteCombo: (id: number) => void;
toggleComboAvailability: (id: number) => void;
// Category actions
addCategory: (category: Omit<MenuCategory, "id">) => void;
updateCategory: (category: MenuCategory) => void;
deleteCategory: (id: string) => void;
}
// ─── Context ──────────────────────────────────────────────────────────────────
const ManagerContext = createContext<ManagerContextType | undefined>(undefined);
// ─── Provider ─────────────────────────────────────────────────────────────────
export function ManagerProvider({ children }: { children: ReactNode }) {
const [products, setProducts] = useState<Product[]>(MOCK_PRODUCTS);
const [combos, setCombos] = useState<Combo[]>(MOCK_COMBOS);
// Filter out the "all" pseudo-category — managers manage real categories only
const [categories, setCategories] = useState<MenuCategory[]>(
MENU_CATEGORIES.filter((c) => c.id !== "all"),
);
const [activeTab, setActiveTab] = useState<ManagerTab>("products");
// ── Product actions ──────────────────────────────────────────────────────
const addProduct = (product: Omit<Product, "id">) => {
const newProduct: Product = {
...product,
id: Date.now(),
};
setProducts((prev) => [...prev, newProduct]);
};
const updateProduct = (product: Product) => {
setProducts((prev) => prev.map((p) => (p.id === product.id ? product : p)));
};
const deleteProduct = (id: number) => {
setProducts((prev) => prev.filter((p) => p.id !== id));
};
const toggleProductAvailability = (id: number) => {
setProducts((prev) =>
prev.map((p) =>
p.id === id ? { ...p, available: !(p.available ?? true) } : p,
),
);
};
// ── Combo actions ────────────────────────────────────────────────────────
const addCombo = (combo: Omit<Combo, "id">) => {
const newCombo: Combo = { ...combo, id: Date.now() };
setCombos((prev) => [...prev, newCombo]);
};
const updateCombo = (combo: Combo) => {
setCombos((prev) => prev.map((c) => (c.id === combo.id ? combo : c)));
};
const deleteCombo = (id: number) => {
setCombos((prev) => prev.filter((c) => c.id !== id));
};
const toggleComboAvailability = (id: number) => {
setCombos((prev) =>
prev.map((c) =>
c.id === id ? { ...c, available: !c.available } : c,
),
);
};
// ── Category actions ─────────────────────────────────────────────────────
const addCategory = (category: Omit<MenuCategory, "id">) => {
const slug = category.name
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
const newCategory: MenuCategory = {
...category,
id: `${slug}-${Date.now()}`,
};
setCategories((prev) => [...prev, newCategory]);
};
const updateCategory = (category: MenuCategory) => {
setCategories((prev) =>
prev.map((c) => (c.id === category.id ? category : c)),
);
};
const deleteCategory = (id: string) => {
setCategories((prev) => prev.filter((c) => c.id !== id));
};
return (
<ManagerContext.Provider
value={{
products,
combos,
categories,
activeTab,
setActiveTab,
addProduct,
updateProduct,
deleteProduct,
toggleProductAvailability,
addCombo,
updateCombo,
deleteCombo,
toggleComboAvailability,
addCategory,
updateCategory,
deleteCategory,
}}
>
{children}
</ManagerContext.Provider>
);
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useManager() {
const context = useContext(ManagerContext);
if (context === undefined) {
throw new Error("useManager must be used within a ManagerProvider");
}
return context;
}
+16
View File
@@ -59,3 +59,19 @@ export interface Shop {
address: string;
image: string;
}
// ===== COMBO TYPES =====
export interface ComboItem {
productId: number;
quantity: number;
}
export interface Combo {
id: number;
name: string;
description: string;
price: number;
image: string;
items: ComboItem[]; // list of products + quantities in this combo
available: boolean;
}