# Bài 17 — Khu quản trị (Admin)
⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️
🎯 Mục tiêu
- Tách khu admin thành route group riêng với layout độc lập
- Sửa lỗi
<html>/<body>lồng nhau — lỗi cấu trúc nghiêm trọng của source gốc - Dựng sidebar, topbar, dashboard thống kê
- Bảng danh sách sản phẩm lấy số liệu thật từ API
⚠️ 1. Vấn đề cấu trúc của source gốc
Cấu trúc hiện tại
app/
├── (user)/
│ ├── layout.js ← có <html> và <body>
│ └── page.js → /
└── admin/
├── layout.js ← CŨNG có <html> và <body> ⚠️
└── page.js → /admin
Vì sao sai?
Trong Next.js App Router, root layout là layout ở cấp cao nhất bao phủ một route,
và chỉ nó được có <html>/<body>.
Vì (user) là route group (không tạo segment URL), Next.js coi
app/(user)/layout.js là root layout cho các route bên trong nó.
Nhưng /admin không nằm trong (user). Nó là nhánh riêng và cũng cần root layout.
Khi app/admin/layout.js khai báo <html>/<body>, Next.js gặp tình huống mơ hồ:
- Nếu nó coi
(user)/layout.jslà root cho cả app → HTML sẽ lồng nhau:<html><body>...<html><body>...</body></html>...</body></html>— HTML không hợp lệ - Hoặc báo lỗi thiếu/thừa root layout
Kết quả trong thực tế: layout admin hiển thị "gần đúng" nhưng React ném warning hydration và một số CSS/JS hoạt động không ổn định.
✅ Cấu trúc đúng
app/
├── (user)/
│ ├── layout.js ← root layout #1: <html><body> + Navbar + Redux
│ ├── page.js → /
│ ├── cart/ → /cart
│ └── menu/ → /menu
└── (admin)/
├── layout.js ← root layout #2: <html><body> + Sidebar + Topbar
└── admin/
├── page.js → /admin
└── product/ → /admin/product
Hai route group song song, mỗi cái có root layout riêng. Đây là pattern chính thức của Next.js cho trường hợp "hai khu vực giao diện hoàn toàn khác nhau".
⚠️ Lưu ý: với cấu trúc này, chuyển từ
/sang/adminsẽ là full page load (tải lại toàn bộ trang), không phải client navigation. Đó là hành vi đúng và mong muốn — hai khu vực có CSS, font, provider khác nhau.
Cách tạo
cd frontend/app
# Windows PowerShell
New-Item -ItemType Directory -Force -Path "(admin)"
Move-Item admin "(admin)/admin"
# macOS / Linux
mkdir "(admin)" && mv admin "(admin)/admin"
Sau đó di chuyển layout.js từ (admin)/admin/ lên (admin)/.
💻 2. Root layout cho khu admin
Tạo frontend/app/(admin)/layout.js:
import "@/public/css/bootstrap.min.css";
import "@/public/css/style.css";
import "@/public/css/fontawesome.css";
import Leftbar from "./component/leftbar";
export const metadata = {
title: "Lith Perfume — Quản trị",
description: "Trang quản trị hệ thống",
};
export default function AdminLayout({ children }) {
return (
<html lang="vi">
<body style={{ background: "#eff8ff" }}>
<div className="d-flex min-vh-100">
{/* ===== Sidebar ===== */}
<div
className="d-flex flex-column flex-shrink-0 p-3 text-bg-primary"
style={{ maxWidth: "280px" }}
data-bs-theme="dark"
>
<a
href="/"
className="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-white text-decoration-none justify-content-center"
>
<span className="fs-4 d-none d-sm-inline-block">LITH ADMIN</span>
</a>
<hr />
<Leftbar />
</div>
{/* ===== Nội dung chính ===== */}
<div className="w-100">
{/* Topbar */}
<nav className="navbar navbar-expand-md text-bg-primary" data-bs-theme="dark">
<div className="container-fluid ps-0">
<div className="d-flex justify-content-between w-100">
<form className="d-flex w-100" role="search" data-bs-theme="light">
<div className="input-group">
<button type="submit" className="btn btn-primary rounded-0 border-white">
<i className="fas fa-search"></i>
</button>
<input
className="form-control me-2 rounded-0 border-white"
type="search"
placeholder="Search"
/>
</div>
</form>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#adminNavbar"
aria-controls="adminNavbar"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span className="navbar-toggler-icon"></span>
</button>
</div>
<div className="collapse navbar-collapse w-100" id="adminNavbar">
<ul className="navbar-nav ms-auto" data-bs-theme="light">
<li className="nav-item dropdown">
<a
className="nav-link dropdown-toggle"
href="#"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<strong><i className="fas fa-bell"></i></strong>
</a>
<ul className="dropdown-menu rounded-0 dropdown-menu-md-end">
<li><a className="dropdown-item" href="#">Thông báo 1</a></li>
<li><a className="dropdown-item" href="#">Thông báo 2</a></li>
</ul>
</li>
<li className="nav-item dropdown">
<a
className="nav-link dropdown-toggle"
href="#"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<strong>Admin</strong>
</a>
<ul className="dropdown-menu rounded-0 dropdown-menu-md-end">
<li><a className="dropdown-item" href="#">Hồ sơ</a></li>
<li><hr className="dropdown-divider" /></li>
<li><a className="dropdown-item" href="/">Về trang bán hàng</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{/* Nội dung trang */}
<div className="container-fluid p-4">{children}</div>
</div>
</div>
<script src="/js/bootstrap.bundle.min.js"></script>
</body>
</html>
);
}
⚠️ Bảy lỗi đã sửa so với source gốc
| # | Lỗi gốc | Đã sửa |
|---|---|---|
| 1 | "use clinet" — sai chính tả |
Bỏ hẳn (layout này là server component, đúng) |
| 2 | class= khắp nơi |
className= |
| 3 | <script src="assets/js/bootstrap.bundle.min.js"> — file không tồn tại |
Xóa dòng thừa |
| 4 | Nạp bootstrap.bundle.min.js 2 lần |
Chỉ 1 lần |
| 5 | Nạp /js/google.chart.js nhưng không dùng |
Xóa |
| 6 | Icon fa-search dạng far (bản trả phí) |
fas (miễn phí) |
| 7 | <img src="https://github.com/mdo.png"> — ảnh mẫu từ template |
Xóa |
| 8 | 2 khối <div className="row"> rỗng |
Xóa |
Về lỗi 1 và metadata
Source gốc có "use clinet" (sai chính tả) và export const metadata.
Nếu bạn sửa thành "use client" cho đúng, Next.js sẽ báo:
You are attempting to export "metadata" from a component marked with "use client"
Đây là ví dụ hay: sửa một lỗi có thể làm lộ ra lỗi khác.
Giải pháp đúng là bỏ hẳn "use client" — layout này không cần nó,
vì phần cần client (usePathname trong sidebar) đã được tách ra Leftbar.
💻 3. Sidebar Leftbar
Tạo frontend/app/(admin)/component/leftbar.js:
"use client"
import Link from "next/link";
import { usePathname } from "next/navigation";
const MENU = [
{ href: "/admin", icon: "fa-tachometer-alt", label: "Dashboard" },
{ href: "/admin/orders", icon: "fa-shopping-cart", label: "Đơn hàng" },
{ href: "/admin/product", icon: "fa-boxes", label: "Sản phẩm" },
{ href: "/admin/customers", icon: "fa-users", label: "Khách hàng" },
{ href: "/admin/ratings", icon: "fa-star-half-alt", label: "Đánh giá" },
];
export default function Leftbar() {
const pathname = usePathname();
return (
<ul className="nav nav-pills flex-column mb-auto">
{MENU.map((item) => (
<li key={item.href} className="nav-item">
<Link
href={item.href}
className={`nav-link rounded-0 ${
pathname === item.href ? "active" : "text-white"
}`}
>
<i className={`fas ${item.icon} fa-fw`}></i>
<span className="d-none d-sm-inline-block ms-2">{item.label}</span>
</Link>
</li>
))}
</ul>
);
}
So với source gốc
Source gốc lặp lại khối <li><Link>...</Link></li> 5 lần. Bản trên dùng mảng + .map() —
thêm menu mới chỉ cần thêm 1 dòng vào MENU.
Cũng đã đổi icon từ far fa-* (bản trả phí) sang fas fa-* (miễn phí),
và thuộc tính data-bs-themes (thừa chữ s, không tồn tại) đã bị xóa.
⚠️ Các link
/admin/orders,/admin/customers,/admin/ratingschưa có trang. Bấm vào sẽ ra 404. Bài 20 sẽ làm trang đơn hàng.
💻 4. Dashboard
Tạo frontend/app/(admin)/admin/page.js:
"use client"
import useSWR from "swr";
import { fetcher, API } from "@/lib/fetcher";
import { formatVND } from "@/lib/price";
function StatCard({ icon, value, label, color }) {
return (
<div className="col-md-3 mb-4">
<div className={`card border-0 rounded-0 bg-${color}-subtle text-${color}`}>
<div className="card-body text-end">
<div className="display-6 d-flex justify-content-between">
<i className={`fas ${icon}`}></i>
<span>{value}</span>
</div>
{label}
</div>
</div>
</div>
);
}
export default function Dashboard() {
const { data: products } = useSWR(`${API}/products`, fetcher);
const { data: orders } = useSWR(`${API}/orders`, fetcher);
const { data: categories } = useSWR(`${API}/categories`, fetcher);
const orderList = Array.isArray(orders) ? orders : [];
const revenue = orderList.reduce((sum, o) => sum + (o.total_money || 0), 0);
return (
<>
<div className="d-flex justify-content-between">
<h3 className="mb-4">Dashboard</h3>
</div>
{/* ===== Thẻ thống kê ===== */}
<div className="row">
<StatCard icon="fa-file-invoice-dollar" value={orderList.length}
label="ĐƠN HÀNG" color="primary" />
<StatCard icon="fa-boxes" value={products?.length ?? "—"}
label="SẢN PHẨM" color="warning" />
<StatCard icon="fa-layer-group" value={categories?.length ?? "—"}
label="DANH MỤC" color="danger" />
<StatCard icon="fa-chart-line" value={formatVND(revenue)}
label="DOANH THU" color="success" />
</div>
{/* ===== Đơn hàng gần đây ===== */}
<div className="row">
<div className="col-md-6 mb-3">
<div className="card rounded-0 border-0 shadow-sm">
<div className="card-body">
<div className="d-flex border-bottom pb-2 justify-content-between">
<h6 className="mb-0">
<i className="fas fa-file-invoice-dollar fa-lg me-2"></i>
Đơn hàng gần đây
</h6>
</div>
{orderList.length === 0 && (
<p className="text-muted mt-3 mb-0">Chưa có đơn hàng nào</p>
)}
{orderList.slice(0, 5).map((order) => (
<div key={order._id} className="d-flex text-body-secondary pt-3">
<div className="p-2 me-2 bg-warning text-white">
<i className="fas fa-receipt"></i>
</div>
<div className="py-2 mb-0 small lh-sm border-bottom w-100">
<strong className="d-flex justify-content-between">
<span>#{order._id.slice(-6).toUpperCase()}</span>
<span>
<span className="badge text-bg-warning me-1">
<i className="fas fa-box"></i> {order.detail?.length ?? 0}
</span>
<span className="badge bg-success-subtle text-success">
{formatVND(order.total_money || 0)}
</span>
</span>
</strong>
Đặt bởi <i>{order.user?.fullname ?? "Khách"}</i>
</div>
</div>
))}
</div>
</div>
</div>
{/* ===== Sản phẩm đánh giá cao ===== */}
<div className="col-md-6 mb-3">
<div className="card rounded-0 border-0 shadow-sm">
<div className="card-body">
<div className="d-flex border-bottom pb-2 justify-content-between">
<h6 className="mb-0">
<i className="fas fa-star fa-lg me-2"></i>
Sản phẩm đánh giá cao
</h6>
</div>
{(products ?? [])
.slice()
.sort((a, b) => (b.rating || 0) - (a.rating || 0))
.slice(0, 5)
.map((p) => (
<div key={p._id} className="d-flex text-body-secondary pt-3">
<div className="py-2 mb-0 small lh-sm border-bottom w-100">
<strong className="d-flex justify-content-between">
<span>{p.name}</span>
<span className="text-warning">
{p.rating} <i className="fas fa-star"></i>
</span>
</strong>
{formatVND(p.price)}
</div>
</div>
))}
</div>
</div>
</div>
</div>
</>
);
}
⚠️ So với source gốc
Dashboard gốc hoàn toàn là số giả:
<div className="display-6">
<i className="fal fa-file-invoice-dollar"></i>
122 {/* ← hardcode */}
</div>
ORDERS
Và phần "Recent Ratings" liệt kê iPhone 15 Pro Max, Samsung Galaxy S23 Ultra —
sản phẩm điện thoại trong website bán nước hoa. Rõ ràng là nội dung mẫu
của template chưa được thay.
Bản trên lấy số liệu thật từ 3 API.
⚠️ Lỗi trong recentOrder.js
{order.totalMoney.toLOcaString()}
// ↑ sai chính tả: đúng là toLocaleString
Ngoài ra component này nhận props order nhưng lại render cả một danh sách cứng
bên dưới. Nó cũng đang bị comment trong admin/page.js, nên chưa gây lỗi thật.
Bản trên viết lại logic đó trực tiếp trong dashboard nên có thể xóa file recentOrder.js.
?. và ?? — hai toán tử hữu ích
products?.length ?? "—"
?.optional chaining — nếuproductslàundefined, trả vềundefinedthay vì crash??nullish coalescing — nếu vế trái lànull/undefined, dùng vế phải
Rất hữu ích với dữ liệu từ API vì lần render đầu data luôn là undefined.
💡 Khác với
||:0 || "—"cho"—", nhưng0 ?? "—"cho0. Với số lượng sản phẩm,??mới đúng (0 sản phẩm là thông tin hợp lệ).
💻 5. Trang danh sách sản phẩm
Tạo frontend/app/(admin)/admin/product/page.js:
"use client"
import Link from "next/link";
import useSWR from "swr";
import { fetcher, API, IMG } from "@/lib/fetcher";
import { formatVND } from "@/lib/price";
export default function AdminProduct() {
const { data: productlist, error, isLoading } = useSWR(`${API}/products`, fetcher);
const { data: categories } = useSWR(`${API}/categories`, fetcher);
if (isLoading) return <div>Loading...</div>;
if (error) return <div className="alert alert-danger">Lỗi: {error.message}</div>;
return (
<>
<div className="d-flex justify-content-between">
<h3 className="mb-4">Sản phẩm</h3>
<div>
<Link href="/admin/product/add" className="btn btn-primary rounded-0">
<i className="fas fa-plus me-1"></i> Thêm sản phẩm
</Link>
</div>
</div>
{/* Thống kê */}
<div className="row">
<div className="col-md-4 mb-4">
<div className="card border-0 rounded-0 bg-primary-subtle text-primary">
<div className="card-body text-end">
<div className="display-6 d-flex justify-content-between">
<i className="fas fa-box"></i>
{productlist.length}
</div>
SẢN PHẨM
</div>
</div>
</div>
<div className="col-md-4 mb-4">
<div className="card border-0 rounded-0 bg-success-subtle text-success">
<div className="card-body text-end">
<div className="display-6 d-flex justify-content-between">
<i className="fas fa-layer-group"></i>
{categories?.length ?? "—"}
</div>
DANH MỤC
</div>
</div>
</div>
<div className="col-md-4 mb-4">
<div className="card border-0 rounded-0 bg-warning-subtle text-warning">
<div className="card-body text-end">
<div className="display-6 d-flex justify-content-between">
<i className="fas fa-star"></i>
{(
productlist.reduce((s, p) => s + (p.rating || 0), 0) /
(productlist.length || 1)
).toFixed(1)}
</div>
ĐÁNH GIÁ TB
</div>
</div>
</div>
</div>
{/* Bảng sản phẩm */}
<div className="card rounded-0 border-0 shadow-sm">
<div className="card-body">
<div className="table-responsive">
<table className="table text-center align-middle">
<thead>
<tr>
<th className="text-start" colSpan="2">Sản phẩm</th>
<th>Giá</th>
<th>Đánh giá</th>
<th>Thao tác</th>
</tr>
</thead>
<tbody>
{productlist.map((product) => {
const fullStars = Math.floor(product.rating || 0);
return (
<tr key={product._id}>
<td style={{ width: 90 }}>
<img
src={`${IMG}${product.image}`}
alt={product.name}
className="w-100"
/>
</td>
<td className="text-start">
<strong>{product.name}</strong>
<br />
<small className="text-muted">
ID: {product._id} | Danh mục:{" "}
<span className="fw-bold">
{product.category?.name ?? "Chưa phân loại"}
</span>
</small>
</td>
<td>{formatVND(product.price)}</td>
<td>
<div className="text-warning">
{product.rating}
{[...Array(fullStars)].map((_, i) => (
<i key={`f${i}`} className="fas fa-star fa-xs"></i>
))}
{[...Array(5 - fullStars)].map((_, i) => (
<i key={`e${i}`} className="far fa-star fa-xs"></i>
))}
</div>
</td>
<td>
<Link
href={`/product/${product._id}`}
target="_blank"
className="btn btn-primary btn-sm me-1"
title="Xem"
>
<i className="fas fa-eye fa-fw"></i>
</Link>
<button className="btn btn-outline-warning btn-sm me-1" title="Sửa">
<i className="fas fa-pencil fa-fw"></i>
</button>
<button className="btn btn-outline-danger btn-sm" title="Xóa">
<i className="fas fa-times fa-fw"></i>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
</>
);
}
⚠️ Bốn lỗi đã sửa
| Lỗi gốc | Đã sửa |
|---|---|
<tr> bọc trong <>...</> bên trong .map() → key gắn sai chỗ |
Bỏ Fragment, key gắn thẳng vào <tr> |
colspan (HTML) |
colSpan (JSX) |
Cột "Instock" hardcode 50, "RUNNING OUT" 3, "ARCHIVE" 0 |
Thay bằng số liệu thật (danh mục, đánh giá TB) |
product.category.name — crash nếu category là null |
product.category?.name ?? "Chưa phân loại" |
Về lỗi cuối: nếu một sản phẩm có categoryId trỏ tới danh mục đã bị xóa,
backend trả về category: null và dòng này khiến cả trang trắng.
Nút Sửa và Xóa hiện chưa hoạt động — bài 20 sẽ làm.
📚 6. Về style.css và fontawesome.css
Khu admin import 2 file CSS riêng:
import "@/public/css/style.css";
import "@/public/css/fontawesome.css";
fontawesome.css tham chiếu tới các file font trong public/font/.
Nếu icon không hiện, mở DevTools → Network → lọc "Font" và xem có 404 không.
⚠️ File
fontawesome.csscủa dự án bao gồm cả Pro icons (fa-light,fa-duotone). Chúng cần font trả phí. Nếu bạn dùng bản miễn phí, các iconfalsẽ hiện thành ô vuông rỗng. Đó là lý do bản trên đổi hết sangfas/far.
✅ 7. Kiểm thử
Mở http://localhost:3000/admin:
- [ ] Sidebar xanh bên trái, có 5 mục menu
- [ ] Mục "Dashboard" đang được highlight
- [ ] 4 thẻ thống kê hiện số thật (số sản phẩm = 12, số danh mục = 10)
- [ ] Nếu đã đặt đơn ở bài 16 → mục "Đơn hàng gần đây" hiện đơn đó
- [ ] Bấm "Sản phẩm" ở sidebar → sang
/admin/product, mục đó được highlight - [ ] Bảng sản phẩm hiện đủ 12 dòng có ảnh, tên, giá, sao đánh giá
- [ ] Bấm icon con mắt → mở tab mới tới trang chi tiết sản phẩm
- [ ] Console không có lỗi hydration hay warning về
<html>lồng nhau
Kiểm tra HTML hợp lệ
Chuột phải → View Page Source. Tìm chuỗi <html. Chỉ được xuất hiện 1 lần.
Nếu thấy 2 lần → cấu trúc route group chưa đúng, xem lại mục 1.
🆘 Xử lý sự cố
<details> <summary>Icon không hiển thị (chỉ thấy ô vuông)</summary>- Kiểm tra
fontawesome.cssđã import chưa - Kiểm tra thư mục
public/font/có đủ file.woff2 - Đổi icon từ
fal/fad(Pro) sangfas/far(miễn phí) - Hoặc dùng CDN:
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
Cấu trúc thư mục sai. Đúng phải là:
app/(admin)/admin/page.js
Route group (admin) không tạo URL, nên thư mục admin/ bên trong mới tạo /admin.
Nếu bạn để app/(admin)/page.js thì URL sẽ là / — xung đột với trang chủ.
Thứ tự import CSS quan trọng. Bootstrap phải nạp trước style.css:
import "@/public/css/bootstrap.min.css";
import "@/public/css/style.css"; // ghi đè lên bootstrap
</details>
<details>
<summary>Dropdown ở topbar không mở</summary>
Thiếu bootstrap.bundle.min.js. Kiểm tra <script src="/js/bootstrap.bundle.min.js">
có ở cuối <body> và đường dẫn có dấu / đầu.
📝 Bài tập
-
Trang
/admin/customers: liệt kê khách hàng duy nhất từ danh sách đơn hàng (gom theo số điện thoại), hiện tên, SĐT, số đơn, tổng chi tiêu. -
Biểu đồ doanh thu: dùng thư viện
recharts:npm install rechartsVẽ biểu đồ cột doanh thu theo ngày từ dữ liệu
orders. -
Nút "Đăng xuất" ở dropdown topbar (chuẩn bị cho bài 19).
-
Sidebar thu gọn: nút toggle thu nhỏ sidebar còn hiện icon, lưu trạng thái vào
localStorage. -
Tìm lỗi tự thân: khôi phục
app/admin/layout.jsvới<html>/<body>như source gốc, vào/adminrồi View Page Source. Đếm số thẻ<html>. Đọc kỹ các warning trong console. -
Kiểm tra khả năng chịu lỗi: trong MongoDB, đổi
categoryIdcủa một sản phẩm thành một ObjectId không tồn tại. Vào/admin/product.- Với code gốc: trang trắng, console báo
Cannot read properties of null - Với code bản này: hiện "Chưa phân loại" ✅
- Với code gốc: trang trắng, console báo
⬅️ Bài trước | Mục lục | Bài tiếp theo: Formik & form upload ➡️
All rights reserved