0

# 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.js là 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 /admin sẽ 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/ratings chư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ếu products là undefined, trả về undefined thay 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ưng 0 ?? "—" cho 0. 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.css củ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 icon fal sẽ hiện thành ô vuông rỗng. Đó là lý do bản trên đổi hết sang fas / 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>
  1. Kiểm tra fontawesome.css đã import chưa
  2. Kiểm tra thư mục public/font/ có đủ file .woff2
  3. Đổi icon từ fal/fad (Pro) sang fas/far (miễn phí)
  4. Hoặc dùng CDN:
    <link rel="stylesheet"
      href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
    
</details> <details> <summary>Vào /admin bị 404</summary>

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ủ.

</details> <details> <summary>Sidebar/topbar hiển thị nhưng CSS vỡ</summary>

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.

</details>

📝 Bài tập

  1. 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.

  2. Biểu đồ doanh thu: dùng thư viện recharts:

    npm install recharts
    

    Vẽ biểu đồ cột doanh thu theo ngày từ dữ liệu orders.

  3. Nút "Đăng xuất" ở dropdown topbar (chuẩn bị cho bài 19).

  4. Sidebar thu gọn: nút toggle thu nhỏ sidebar còn hiện icon, lưu trạng thái vào localStorage.

  5. Tìm lỗi tự thân: khôi phục app/admin/layout.js với <html>/<body> như source gốc, vào /admin rồi View Page Source. Đếm số thẻ <html>. Đọc kỹ các warning trong console.

  6. Kiểm tra khả năng chịu lỗi: trong MongoDB, đổi categoryId củ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" ✅

⬅️ Bài trước | Mục lục | Bài tiếp theo: Formik & form upload ➡️


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí