0

Bài 32 — Trang quản trị: CRUD, phân trang, upload ảnh Cloudinary Phần 1

Phần 5 · Từng chức năng, từ đầu đến cuối — Thời lượng ước tính: ~150 phút ⬅️ Bài trước: 31 — Tin tức, liên hệ, cửa hàng và slider trang chủ · Bài sau: 33 — Rà soát bảo mật: dự án đang sai ở đâu ➡️ 🏠 Mục lục


🎯 Sau bài này bạn sẽ

  • Đọc hiểu được toàn bộ khu vực /admin: 8 nhóm màn hình quản trị được dựng theo cùng một khuôn mẫu.
  • Mổ được AdminLayout.js — sidebar, menu, <Outlet /> — và biết vì sao mục Profile bấm vào là hỏng.
  • Nắm được khuôn mẫu chung: trang danh sách (gọi API → phân trang → sửa/xoá) → trang thêm (react-hook-form + yup + upload ảnh) → trang sửa (reset() nạp dữ liệu cũ).
  • Tự tạo tài khoản Cloudinary miễn phí của riêng mình, tạo unsigned upload preset, và hiểu hàm uploadFile() làm gì từng dòng.
  • Đọc được bảng trạng thái đơn hàng (status 0→4) và biết admin đổi trạng thái đơn bằng cách nào.
  • Tự viết trọn 3 màn quản trị Topping (danh sách / thêm / sửa) + route + mục menu — bài tổng duyệt trước đồ án cuối khoá.

📋 Cần chuẩn bị


0. Sơ đồ luồng — "Thêm một sản phẩm" đi trọn các lớp

Đây là chức năng đại diện cho cả khu admin. Mọi màn "thêm" khác (tin tức, danh mục, slider, user) chỉ là bản sao của luồng này với tên field khác.

flowchart TD
  A["<b>AddProductPage.js</b><br/>form 6 ô + input file"] -->|"handleSubmit(onSubmit)"| B["<b>yup schema</b><br/>name / price / description /<br/>categoryId / image / status"]
  B -->|"hợp lệ"| C["<b>onSubmit(data)</b>"]
  B -->|"lỗi"| B2["errors.xxx?.message<br/>hiện chữ đỏ dưới ô"]

  C --> D["<b>uploadFile(data.image[0])</b><br/>yotea-fe/src/utils/index.js"]
  D -->|"POST multipart/form-data"| E["<b>Cloudinary</b><br/>api.cloudinary.com/v1_1/&lt;cloud&gt;/image/upload"]
  E -->|"{ url, secure_url, ... }"| D
  D -->|"trả về data.url"| F["<b>addProduct(...)</b><br/>RTK Query mutation<br/>api/product.js"]

  F -->|"POST /api/products/:userId<br/>+ Bearer token"| G["<b>routes/product.js</b><br/>requireSignin → isAuth → isAdmin"]
  G --> H["<b>controllers/product.js</b><br/>create()<br/>slugify(name)"]
  H --> I["<b>models/product.js</b><br/>new Product(req.body).save()"]
  I --> J[("<b>MongoDB</b><br/>collection products")]

  J -->|"document vừa lưu"| H
  H -->|"res.json(product)"| F
  F -->|"invalidatesTags: ['Product']"| K["cache RTK Query bẩn<br/>→ ListProduct tự gọi lại"]
  F -->|".unwrap().then(...)"| L["toast.success + reset()<br/>+ setPreview('')"]

Còn đây là đường về vẽ phẳng cho dễ nhớ — cùng một mạch, đọc từ trái sang phải rồi quay ngược lại:

   TRÌNH DUYỆT                          MẠNG                    SERVER              DB
┌──────────────────┐              ┌────────────────┐      ┌───────────────┐   ┌──────────┐
│ AddProductPage   │              │                │      │               │   │          │
│  ├ react-hook-form│              │                │      │               │   │          │
│  ├ yup validate  │              │                │      │               │   │          │
│  ├ uploadFile ───┼──── ① ──────►│ Cloudinary API │      │               │   │          │
│  │            ◄──┼──── ② ───────│ (trả về url)   │      │               │   │          │
│  └ addProduct ───┼──── ③ ──────►│ POST /api/     │─────►│ router        │   │          │
│                  │              │  products/:uid │      │  ↓ middleware │   │          │
│                  │              │                │      │  ↓ controller │──►│ products │
│                  │              │                │      │  ↓ model      │◄──│          │
│  toast + reset ◄─┼──── ④ ───────┼────────────────┼──────│ res.json()    │   │          │
│  cache invalid ──┼──── ⑤ ──────►│ GET /api/      │─────►│ list()        │──►│ products │
│  bảng tự vẽ lại ◄┼──────────────┼────────────────┼──────│               │◄──│          │
└──────────────────┘              └────────────────┘      └───────────────┘   └──────────┘

💡 Điểm quan trọng nhất của sơ đồ: ảnh KHÔNG đi qua server Yotea. Trình duyệt tự đẩy file lên Cloudinary (bước ①②), rồi chỉ gửi một chuỗi URL về backend (bước ③). Backend Yotea không hề biết đến file ảnh, không có thư mục uploads/, không cài multer.


1. Khu /admin gồm những gì

1.1. Cửa vào

yotea-fe/src/App.js:188-199

    {
      path: "/admin",
      element: (
        <PrivateRouter page="admin">
          <AdminLayout />
        </PrivateRouter>
      ),
      children: [
        {
          path: "",
          element: <Dashboard />,
        },

Mọi thứ trong /admin đều nằm bên trong <PrivateRouter page="admin"> (đã học ở Bài 24) và bên trong <AdminLayout />. Muốn thêm màn quản trị mới, bạn chỉ việc thêm một phần tử vào mảng children này.

1.2. Điểm danh đủ 8 nhóm màn hình quản trị

# Nhóm URL gốc Trang danh sách Trang thêm Trang sửa / chi tiết Phân trang?
1 Đơn hàng /admin/cart cart/CartListPage.js — (khách tự đặt) cart/CartDetailPage.js ✅
2 Người dùng /admin/user user/UserListPage.js user/AddUserPage.js user/EditUserPage.js ✅
3 Tin tức /admin/news news/NewsListPage.js news/AddNewsPage.js news/EditNewsPage.js ✅
4 Danh mục tin /admin/category-news category-news/CateNewsListPage.js AddCateNewsPage.js EditCateNewsPage.js ❌
5 Sản phẩm /admin/product product/ProductListPage.js product/AddProductPage.js product/EditProductPage.js ✅
6 Danh mục SP /admin/category category/CategoryListPage.js category/AddCategoryPage.js category/EditCategoryPage.js ❌
7 Slider /admin/slider slider/SliderListPage.js slider/AddSlidePage.js slider/EditSlidePage.js ❌
8 Liên hệ /admin/contact contact/ContactListPage.js — (khách tự gửi) contact/ContactDetailPage.js ✅

Cộng thêm Dashboard.js là 9 mục trong sidebar.

1.3. Những màn là code chết

File Vì sao chết
pages/admin/profile/AdminUpdateInfoPage.js App.js không import, không có route /admin/profile
pages/admin/profile/AdminUpdatePassword.js như trên
components/admin/StoreList.js không file nào import; cũng không có route /admin/store
components/admin/AdminCommentList.js không file nào import → màn kiểm duyệt bình luận chưa được nối vào router

Bạn có thể tự kiểm chứng bằng lệnh sau (đứng ở gốc repo):

grep -rn "AdminCommentList\|StoreList\|AdminUpdateInfoPage" yotea-fe/src

Kết quả chỉ ra đúng dòng khai báo và dòng export default trong chính file đó — không có nơi nào import.

⚠️ Chỗ này dự án làm chưa chuẩn: viết xong 4 file rồi quên nối vào router là chuyện rất hay xảy ra ở bài tập lớn. Cách phòng: mỗi khi tạo một page mới, việc đầu tiên là thêm route và bấm thử URL đó, rồi mới ngồi viết giao diện. Ngoài ra hãy bật ESLint rule no-unused-modules hoặc chạy npx depcheck / npx unimported định kỳ để soi file mồ côi.


2. Mổ AdminLayout.js — bộ khung của mọi màn quản trị

File: yotea-fe/src/pages/layouts/AdminLayout.js (236 dòng). Lưu ý đường dẫn: nó nằm trong pages/layouts/, không phải layouts/.

2.1. Đầu file — lấy user và hàm đăng xuất

yotea-fe/src/pages/layouts/AdminLayout.js:17-24

const AdminLayout = () => {
  const { user } = useSelector(selectAuth);

  const dispatch = useDispatch();

  const handleLogout = () => {
    dispatch(logout());
  };

Đọc từng dòng:

Dòng Code Ý nghĩa
18 const { user } = useSelector(selectAuth); Lấy state.auth.value.user — dùng để hiện avatar ở góc phải
20 const dispatch = useDispatch(); Chuẩn bị để bắn action
22-24 handleLogout dispatch(logout()) — reducer đặt value = {} và isLogged = false, PrivateRouter lập tức đá về /login

2.2. Sidebar — 9 mục menu

Sidebar là thẻ <nav className="dashboard__sidebar ..."> cố định bên trái, rộng w-60. Mỗi mục là một <NavLink>. Đây là mục đầu tiên, trích nguyên văn:

yotea-fe/src/pages/layouts/AdminLayout.js:40-53

            <NavLink
              to="/admin/"
              className="sidebar__item flex items-center px-4 py-3 transition cursor-pointer group hover:bg-gray-800 hover:text-gray-200"
            >
              <svg
                className="shrink-0 w-5 h-5 mr-2 text-gray-400 transition group-hover:text-gray-300"
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 20 20"
                fill="currentcolor"
              >
                <path d="M10.707 2.293a1 1 0 00-1.414.0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
              </svg>
              <span>Dashboard</span>
            </NavLink>

8 mục còn lại có cấu trúc y hệt, chỉ khác to, icon và chữ:

Dòng to Icon Nhãn
40-53 /admin/ SVG ngôi nhà (inline) Dashboard
55-63 /admin/cart faShoppingCart Cart
65-75 /admin/user faNewspaper ⚠️ Users
77-87 /admin/news faNewspaper News
89-99 /admin/category-news faThList Categories News
101-111 /admin/product faProductHunt Products
113-123 /admin/category faThList Categories Products
125-135 /admin/slider faSlidersH Slider
137-147 /admin/contact faComment Contact

⚠️ Chỗ này dự án làm chưa chuẩn: mục Users dùng icon faNewspaper (tờ báo) — copy-paste từ mục News mà quên đổi. Icon đúng phải là faUsers / faUser. Ngoài ra AdminLayout.js:3,9 import faCode và faStore nhưng không dùng ở đâu → import thừa.

⚠️ Không mục nào có route con page/:page trong menu, nên khi bạn đang ở /admin/product/page/3 thì NavLink "Products" không được tô sáng. NavLink chỉ tự thêm class active khi URL khớp; ở đây dự án cũng không truyền hàm className={({isActive}) => ...} nên thực ra không mục nào bao giờ đổi màu — chỉ có hover.

2.3. Thanh header trên cùng và <Outlet />

yotea-fe/src/pages/layouts/AdminLayout.js:225-227

          <main>
            <Outlet />
          </main>

📖 Thuật ngữ: <Outlet /> — cái "lỗ" trong layout để React Router nhét route con vào. Khi URL là /admin/product, router render <AdminLayout /> rồi thay <Outlet /> bằng <ProductListPage />. Nhờ vậy sidebar và header không bị vẽ lại khi bạn chuyển màn.

Vùng nội dung được đẩy sang phải bằng className="ml-0 transition md:ml-60" (:150) để không bị sidebar w-60 che.

2.4. ⚠️ Link chết /admin/profile

yotea-fe/src/pages/layouts/AdminLayout.js:208-213

                  <Link
                    to="/admin/profile"
                    className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
                  >
                    Profile
                  </Link>

⚠️ Chỗ này dự án làm chưa chuẩn — LINK CHẾT: trong App.js không có route nào khớp /admin/profile (xem lại bảng route ở mục 1.2). Bấm vào mục Profile trong menu avatar → React Router không tìm ra route, mà App.js cũng không khai báo errorElement → bạn nhận được trang trắng kèm thông báo lỗi mặc định của router.

Hai file đáng lẽ phải phục vụ link này — pages/admin/profile/AdminUpdateInfoPage.js và AdminUpdatePassword.js — đã viết xong nhưng không được import vào App.js.

Cách làm đúng: hoặc (a) thêm route { path: "profile", element: <AdminUpdateInfoPage /> } vào mảng children của /admin, hoặc (b) xoá luôn mục menu. Và luôn khai báo errorElement cho route gốc để URL sai ra trang 404 tử tế thay vì màn trắng. Ta sẽ làm cả hai ở Bài 34.

2.5. Những nút chỉ để trang trí

Vị trí Thứ Vấn đề
:152-157 Nút ☰ mở sidebar trên mobile Không có onClick → trên điện thoại sidebar -translate-x-full không bao giờ mở ra được
:158-167 Ô Search ở header Không onSubmit, không state
:169-190 Chuông thông báo Không onClick
:229 <div ... dashboard__overlay /> Lớp phủ khi mở sidebar mobile, hidden cứng, không ai bật

⚠️ Đây là dấu vết của việc bê nguyên một template HTML tĩnh (Tailwind UI) vào React mà chưa nối logic. Rất phổ biến trong đồ án. Khi bạn gặp một nút "không làm gì", đừng vội nghĩ mình đọc sót — hãy grep tên class/id của nó, thường là không có handler thật.


3. Mổ Dashboard.js — sự thật phũ phàng

Toàn bộ file, yotea-fe/src/pages/admin/Dashboard.js:1-7:

import React from "react";

const Dashboard = () => {
  return <div>Dashboard</div>;
};

export default Dashboard;

Nó hiển thị số liệu gì? — Không có số liệu nào. Bảy dòng, in ra đúng chữ Dashboard.

  • Không useState, không useEffect, không gọi API, không useSelector.
  • Không có ô "Doanh thu hôm nay", không biểu đồ, không đếm đơn hàng / sản phẩm / user.
  • Có tính đúng không? Câu hỏi không áp dụng — không có phép tính nào để mà sai.

⚠️ Chỗ này dự án làm chưa chuẩn: route mặc định của cả khu quản trị (/admin) trỏ vào một component rỗng. Người dùng đăng nhập admin xong nhìn thấy màn hình gần như trắng.

Cách làm đúng (gợi ý để bạn tự làm ở phần Bài tập): Dashboard nên gọi vài API sẵn có rồi tính: tổng đơn (GET /api/orders → data.length), doanh thu (reduce cộng totalPrice - priceDecrease của các đơn status === 3), tổng sản phẩm, tổng user. Tuy nhiên backend không trả về total trong header hay body, nên hiện tại muốn đếm là phải tải hết bản ghi về rồi lấy .length — đúng cái anti-pattern mà cả dự án đang mắc (xem mục 4.2).


4. KHUÔN MẪU CHUNG của mọi màn quản trị

Đây là mục quan trọng nhất bài. Nắm được khuôn này thì 8 nhóm màn hình kia bạn đọc lướt trong 5 phút.

                       ┌─────────────────────────────────────────┐
   /admin/<res>        │  TRANG DANH SÁCH  (<Res>ListPage.js)    │
   /admin/<res>/page/n │  ├ đọc :page từ useParams()             │
                       │  ├ công thức phân trang (limit=10)      │
                       │  ├ <List<Res> start limit />  ← bảng    │
                       │  │    ├ gọi API / thunk / RTK Query     │
                       │  │    ├ nút Edit → Link                 │
                       │  │    └ nút Delete → Swal → xoá         │
                       │  └ <AdminPagination page totalPage url/>│
                       └────────────┬───────────────┬────────────┘
                    "Thêm" ─────────┘               └───────── "Edit"
                        ▼                                     ▼
      ┌──────────────────────────────┐      ┌──────────────────────────────┐
      │ TRANG THÊM (Add<Res>Page.js) │      │ TRANG SỬA (Edit<Res>Page.js) │
      │ ├ schema yup (CÓ image)      │      │ ├ schema yup (BỎ image)      │
      │ ├ useForm + yupResolver      │      │ ├ useEffect → get(slug|id)   │
      │ ├ preview ảnh (useState)     │      │ ├ reset(data) nạp giá trị cũ │
      │ ├ uploadFile(...) → url      │      │ ├ chỉ upload NẾU chọn ảnh mới│
      │ ├ POST                       │      │ ├ PUT                        │
      │ └ toast.success + reset()    │      │ └ toast.success + navigate() │
      └──────────────────────────────┘      └──────────────────────────────┘

4.1. Trang danh sách — công thức phân trang lặp y hệt 5 lần

yotea-fe/src/pages/admin/product/ProductListPage.js:6-17

const ProductListPage = () => {
  const { data: products } = useGetProductsQuery({});
  const totalItem = products?.length || 0;

  const { page } = useParams();

  const limit = 10;
  const totalPage = Math.ceil(totalItem / limit);
  let currentPage = Number(page) || 1;
  currentPage =
    currentPage < 1 ? 1 : currentPage > totalPage ? totalPage : currentPage;
  const start = (currentPage - 1) * limit > 0 ? (currentPage - 1) * limit : 0;

Đọc từng dòng:

Dòng Code Ý nghĩa
7 useGetProductsQuery({}) Gọi RTK Query không truyền start/limit → tải TOÀN BỘ sản phẩm
8 products?.length \|\| 0 Dùng độ dài mảng làm tổng số bản ghi
10 const { page } = useParams() Lấy số trang từ URL /admin/product/page/3 → page = "3" (chuỗi!)
12 const limit = 10 Mỗi trang 10 dòng — hằng số cứng, lặp ở cả 5 trang list
13 Math.ceil(totalItem / limit) 27 bản ghi ÷ 10 = 2.7 → 3 trang
14 Number(page) \|\| 1 Đổi chuỗi sang số; undefined/NaN/0 đều rơi về 1
15-16 ternary kẹp Ép currentPage nằm trong [1, totalPage]
17 start Bỏ qua bao nhiêu bản ghi: trang 3 → (3-1)*10 = 20

Đoạn 6 dòng này (từ const limit = 10) lặp nguyên xi ở CartListPage:11-16, ContactListPage:12-17, NewsListPage:12-17, ProductListPage:12-17, UserListPage:14-19.

⚠️ Chỗ này dự án làm chưa chuẩn — 2 request cho 1 trang: ProductListPage gọi useGetProductsQuery({}) để lấy tổng, rồi ListProduct bên trong lại gọi useGetProductsQuery({ start, limit }) để lấy đúng 10 dòng. Mỗi lần vào trang là 2 request, và request đầu kéo về toàn bộ bảng sản phẩm chỉ để đếm.

Nguyên nhân gốc: backend list() trả về mảng trần, không kèm tổng số. Cách đúng là backend trả { data: [...], total: 27 } hoặc set header X-Total-Count, rồi FE đọc header đó. Ta sẽ sửa ở Bài 34.

⚠️ Bug clamp khi bảng rỗng: totalItem = 0 → totalPage = 0 → dòng 15-16 thấy currentPage (1) > totalPage (0) nên gán currentPage = 0. Kết quả <AdminPagination page={0}>. May là AdminPagination chỉ render dãy số khi totalPage > 1 nên không lộ ra, nhưng logic vẫn sai. Cách đúng: const totalPage = Math.max(1, Math.ceil(totalItem / limit));

4.2. Bảng dữ liệu + nút Xoá với SweetAlert2

yotea-fe/src/components/admin/ListProduct.js:8-30

const ListProduct = ({ start, limit }) => {
  const { data: products } = useGetProductsQuery({ start, limit });
  const [deleteProduct] = useDeleteProductMutation();

  const handleRemove = async (id) => {
    Swal.fire({
      title: "Bạn có chắc chắn muốn xóa không?",
      text: "Bạn không thể hoàn tác sau khi xóa!",
      icon: "warning",
      showCancelButton: true,
      confirmButtonColor: "#3085d6",
      cancelButtonColor: "#d33",
      confirmButtonText: "Yes, delete it!",
    }).then((result) => {
      if (result.isConfirmed) {
        deleteProduct(id)
          .unwrap()
          .then(() => {
            Swal.fire("Thành công!", "Đã xóa thành công.", "success");
          });
      }
    });
  };

📖 Thuật ngữ: SweetAlert2 (Swal) — thư viện hộp thoại đẹp thay cho alert()/confirm() xấu xí của trình duyệt. Swal.fire({...}) trả về một Promise; khi người dùng bấm nút, promise resolve với object result, trong đó result.isConfirmed === true nghĩa là họ bấm nút xác nhận.

Khối Swal.fire này được copy y hệt ở 8 file: CategoryListPage, CateNewsList, ContactList, ListProduct, ListSlider, NewsList, StoreList, UserList, AdminCommentList.

✅ ListProduct là component xoá DUY NHẤT làm đúng. Nó dùng .unwrap().then(...) — chỉ báo "Đã xóa thành công" sau khi server trả 2xx.

⚠️ 6 component còn lại viết kiểu này (ví dụ ContactList.js:31-32):

dispatch(deleteContact(id));
Swal.fire("Thành công!", "Đã xóa thành công.", "success");

dispatch(thunk) trả về promise nhưng không ai chờ, và createAsyncThunk nuốt lỗi (không throw ra ngoài). Kết quả: server trả 401/500 thì người dùng vẫn thấy "Đã xóa thành công" trong khi dòng dữ liệu vẫn còn nguyên sau khi F5. Cách đúng: await dispatch(deleteContact(id)).unwrap() bọc trong try/catch.

⚠️ Ngay cả ListProduct cũng thiếu .catch() → xoá lỗi thì im lặng, không báo gì.

Phần thân bảng, yotea-fe/src/components/admin/ListProduct.js:95-102:

        {products?.map((item, index) => (
          <tr key={index}>
            <td /* ...class Tailwind... */>
              {++index + start}
            </td>
            <td /* ...class Tailwind... */>
              {item._id}
            </td>

⚠️ {++index + start} — sửa thẳng tham số index của .map. Nó chạy được (STT trang 3 bắt đầu từ 21) nhưng là anti-pattern kinh điển: biến của callback không nên bị mutate. Lặp ở 6 file. Cách đúng: {index + 1 + start}.

⚠️ key={index} — dùng chỉ số mảng làm key. Khi xoá dòng giữa bảng, React ghép nhầm DOM cũ với dữ liệu mới. Cách đúng: key={item._id}.

Cuối mỗi dòng là cặp nút Edit/Delete, yotea-fe/src/components/admin/ListProduct.js:141-152:

              <Link
                to={`/admin/product/${item.slug}/edit`}
                className=/* ...class Tailwind... */
              >
                Edit
              </Link>
              <button
                className=/* ...class Tailwind... */
                onClick={() => handleRemove(item._id)}
              >
                Delete
              </button>

Chú ý: Edit dùng slug, Delete dùng _id. Hai khoá khác nhau trong cùng một dòng bảng — nhớ kỹ điều này, mục 6 sẽ cho thấy nó gây bug thế nào.

4.3. Trang thêm — react-hook-form + yup + toast

Bộ ba luôn đi cùng nhau:

Thư viện Việc
react-hook-form Quản lý giá trị các ô input mà không cần useState cho từng ô
yup + @hookform/resolvers/yup Mô tả luật hợp lệ bằng schema, tự sinh thông báo lỗi tiếng Việt
react-toastify Bật thông báo nhỏ ở góc màn hình (<ToastContainer /> đặt ở App.js:356)

Xem chi tiết ở mục 5.

4.4. Trang sửa — bí quyết reset()

Trang sửa khác trang thêm đúng ba điểm:

  1. Schema bỏ trường ảnh (để sửa mà không bắt chọn lại ảnh).
  2. Có useEffect nạp bản ghi cũ rồi gọi reset(data) để đổ vào form.
  3. Chỉ upload ảnh khi người dùng thực sự chọn file mới.

📖 Thuật ngữ: reset(values) — hàm của react-hook-form, ghi đè toàn bộ giá trị form bằng object bạn truyền vào. Đây là cách chuẩn để "nạp dữ liệu cũ" vì dữ liệu về bất đồng bộ (sau khi component đã render xong), nên không thể dùng defaultValues lúc khởi tạo useForm.


5. Soi kỹ AddProductPage.js

5.1. Schema yup

yotea-fe/src/pages/admin/product/AddProductPage.js:15-27

const schema = yup.object().shape({
  name: yup.string().required("Vui lòng nhập tên sản phẩm"),
  price: yup
    .string()
    .required("Vui lòng nhập giá sản phẩm")
    .test("min", "Vui lòng nhập lại giá SP", (value) => Number(value) >= 0),
  description: yup.string().required("Vui lòng nhập mô tả SP"),
  categoryId: yup.string().required("Vui lòng chọn danh mục SP"),
  image: yup
    .mixed()
    .test("require", "Vui lòng chọn ảnh SP", (value) => value.length),
  status: yup.number().required("Vui lòng chọn trạng thái SP"),
});

Đọc từng dòng:

Dòng Luật Giải thích
16 name bắt buộc chuỗi rỗng → báo lỗi
17-20 price là string rồi mới Number(value) >= 0 yup.mixed().test(tên, thông_báo, hàm_kiểm); hàm trả false là lỗi
21 description bắt buộc
22 categoryId bắt buộc giá trị là _id của danh mục
23-25 image là mixed() + value.length <input type="file"> trả về một FileList; chưa chọn file thì length === 0 → falsy → lỗi. Đây chính là chỗ "bắt buộc phải upload ảnh khi THÊM"
26 status là number yup tự ép "1" → 1

⚠️ Chỗ này dự án làm chưa chuẩn — 3 lỗ hổng validate:

  1. price: yup.string() + Number(value) >= 0: Number("") === 0 nên ô giá để trống vẫn lọt qua test min (may là .required() chặn được), còn Number("abc") là NaN, và NaN >= 0 là false nên báo lỗi — đúng nhưng thông báo sai ngữ nghĩa. Cách đúng: yup.number().typeError("Giá phải là số").min(0, "...").required("...").
  2. status: yup.number().required(): chọn option placeholder "" → yup ép thành NaN, mà NaN không phải undefined nên .required() vẫn pass → có thể submit khi chưa chọn trạng thái. Cách đúng: thêm .typeError("Vui lòng chọn trạng thái SP").
  3. image: value.length — nếu value là undefined (trình duyệt lạ) thì đọc .length gây TypeError trong resolver. Cách đúng: (value) => value?.length > 0.

5.2. Ô chọn danh mục lấy dữ liệu từ đâu

yotea-fe/src/pages/admin/product/AddProductPage.js:29-41

const AddProductPage = () => {
  const dispatch = useDispatch();
  const categories = useSelector(selectCatesProduct);
  const [addProduct] = useAddProductMutation();
  const [preview, setPreview] = useState();

  useEffect(() => {
    dispatch(getCates());
  }, []);

  const handlePreview = (e) => {
    setPreview(URL.createObjectURL(e.target.files[0]));
  };

Đường đi của danh sách danh mục:

useEffect  →  dispatch(getCates())            [redux/categoryProductSlice.js:8-14]
              └─ getAll()                     [api/category.js:7-11]
                 └─ GET /api/category/?_sort=createdAt&_order=desc
                    └─ routes/category.js → controllers/category.js list()
                       └─ Category.find(...)  → MongoDB
              ← payload  →  state.cateProduct.value
useSelector(selectCatesProduct)               [categoryProductSlice.js:64]
              → categories → <option> trong <select>

Và đây là ô <select>, yotea-fe/src/pages/admin/product/AddProductPage.js:166-178:

                  <select
                    id="form__add-product-cate"
                    {...register("categoryId")}
                    defaultValue=""
                    className=/* ...class Tailwind... */
                  >
                    <option value="">-- Chọn danh mục sản phẩm --</option>
                    {categories?.map((cate, index) => (
                      <option key={index} value={cate._id}>
                        {cate.name}
                      </option>
                    ))}
                  </select>

Điểm mấu chốt: value={cate._id} — cái gửi lên server là ObjectId của danh mục, còn {cate.name} chỉ là chữ cho người xem.

Ô trạng thái, yotea-fe/src/pages/admin/product/AddProductPage.js:190-199:

                  <select
                    id="form__add-product-stt"
                    {...register("status")}
                    defaultValue={0}
                    className=/* ...class Tailwind... */
                  >
                    <option value="">-- Chọn trạng thái sản phẩm --</option>
                    <option value={0}>Ẩn</option>
                    <option value={1}>Hiển thị</option>
                  </select>

⚠️ defaultValue={0} đặt trên <select> đã có {...register(...)} là xung đột: react-hook-form mới là bên quản lý giá trị. Cách đúng: useForm({ resolver, defaultValues: { status: 0 } }).

5.3. Ô chọn file và preview

yotea-fe/src/pages/admin/product/AddProductPage.js:245-251

                          <input
                            id="form__add-product-image"
                            {...register("image")}
                            onChange={(e) => handlePreview(e)}
                            type="file"
                            className="sr-only"
                          />

Đọc từng dòng:

Dòng Ý nghĩa
247 {...register("image")} trải ra { name, onChange, onBlur, ref } của react-hook-form
248 onChange viết SAU spread → ghi đè onChange của RHF
250 className="sr-only" — giấu input thật, người dùng bấm vào <label> đẹp ở trên (:240-244)

⚠️ Chỗ này dự án làm chưa chuẩn — thứ tự spread: vì onChange của RHF bị ghi đè, react-hook-form chỉ còn nắm được file thông qua ref (RHF v7 đọc ref.current.files lúc submit) → may mà vẫn chạy. Nhưng đây là pattern rất dễ vỡ. Cách đúng:

// đoạn này bạn tự viết thêm, dự án chưa có
const { onChange: rhfOnChange, ...rest } = register("image");
<input type="file" {...rest} onChange={(e) => { rhfOnChange(e); handlePreview(e); }} />

⚠️ URL.createObjectURL(...) (:40) không bao giờ được URL.revokeObjectURL → mỗi lần chọn ảnh lại giữ thêm một blob trong bộ nhớ. Lặp ở 9 form. Cách đúng: lưu url cũ và gọi URL.revokeObjectURL(urlCu) trước khi tạo url mới, hoặc dọn trong useEffect cleanup.

⚠️ Input không có accept="image/*", không kiểm dung lượng dù UI ghi "PNG, JPG, GIF up to 10MB" (:256) — dòng chữ đó chỉ là trang trí.

5.4. onSubmit — nơi mọi thứ gặp nhau

yotea-fe/src/pages/admin/product/AddProductPage.js:50-69

  const onSubmit = async (data) => {
    try {
      const url = await uploadFile(data.image[0]);

      addProduct({
        ...data,
        image: url,
        price: +data.price,
        status: +data.status,
      })
        .unwrap()
        .then(() => {
          toast.success("Thêm sản phẩm thành công");
          setPreview("");
          reset();
        });
    } catch (error) {
      toast.error("Có lỗi xảy ra, vui lòng thử lại");
    }
  };

Đọc từng dòng:

Dòng Code Ý nghĩa
52 uploadFile(data.image[0]) data.image là FileList; [0] là File đầu tiên. Chờ upload xong mới đi tiếp
54 addProduct({...}) Mutation của RTK Query → POST /api/products/:userId
57 price: +data.price Dấu + đứng trước chuỗi là cách ép sang số ngắn gọn: +"35000" === 35000
58 status: +data.status tương tự
60 .unwrap() Biến kết quả mutation thành promise "thật": thành công thì resolve, lỗi thì reject
61-65 .then(...) Chỉ chạy khi server trả 2xx: toast + xoá preview + reset() làm trắng form

⚠️ Chỗ này dự án làm chưa chuẩn — try/catch bắt hụt: khối try chỉ ôm lời gọi đồng bộ. Khi .unwrap() reject (API 400/401/500), promise vỡ ngoài phạm vi try → trình duyệt in Unhandled promise rejection, không có toast lỗi, người dùng bấm mãi không hiểu vì sao. Cách đúng: thêm .catch(() => toast.error("...")) sau .then(...), hoặc dùng await addProduct({...}).unwrap() để lỗi rơi đúng vào catch.


6. Soi kỹ EditProductPage.js

6.1. Schema — bỏ hẳn image

yotea-fe/src/pages/admin/product/EditProductPage.js:15-24

const schema = yup.object().shape({
  name: yup.string().required("Vui lòng nhập tên sản phẩm"),
  price: yup
    .string()
    .required("Vui lòng nhập giá sản phẩm")
    .test("min", "Vui lòng nhập lại giá SP", (value) => Number(value) >= 0),
  description: yup.string().required("Vui lòng nhập mô tả SP"),
  categoryId: yup.string().required("Vui lòng chọn danh mục SP"),
  status: yup.number().required("Vui lòng chọn trạng thái SP"),
});

So với AddProductPage.js:15-27 — thiếu đúng khối image. Đó là chủ ý: sửa sản phẩm mà không muốn đổi ảnh thì để trống ô file.

6.2. Nạp dữ liệu cũ bằng reset()

yotea-fe/src/pages/admin/product/EditProductPage.js:64-76

  useEffect(() => {
    dispatch(getCates());

    const getProduct = async () => {
      const { data } = await get(slug);
      setPreview(data.image);
      reset({
        ...data,
        categoryId: data.categoryId?._id,
      });
    };
    getProduct();
  }, []);

Đọc từng dòng:

Dòng Ý nghĩa
65 Nạp danh sách danh mục cho <select> (giống trang thêm)
68 get(slug) → GET /api/products/<slug>/?_expand=categoryId
69 Đổ URL ảnh cũ vào ô preview → người dùng thấy ngay ảnh hiện tại
70-73 reset() đổ toàn bộ bản ghi vào form
72 Điểm dạy quan trọng: vì có _expand=categoryId, data.categoryId là cả object danh mục, không phải chuỗi id. <select> chỉ so khớp được với chuỗi, nên phải rút ._id ra

💡 Mẹo: mỗi khi trang sửa có <select> mà "không tự chọn đúng mục cũ", 90% là do bạn quên chuyển object đã populate về _id như dòng 72.

6.3. Giữ ảnh cũ nếu không chọn ảnh mới

yotea-fe/src/pages/admin/product/EditProductPage.js:43-58

  const onSubmit = async (data) => {
    try {
      if (typeof data.image === "object" && data.image.length) {
        data.image = await uploadFile(data.image[0]);
      }

      updateProduct(data)
        .unwrap()
        .then(() => {
          toast.success("Cập nhật SP thành công");
          navigate("/admin/product");
        });
    } catch (error) {
      toast.error("Có lỗi xảy ra, vui lòng thử lại");
    }
  };

Dòng 45 là trái tim của trang sửa. Giải thích thật kỹ:

Tình huống data.image là gì typeof .length Kết quả
Người dùng không chọn ảnh mới Chuỗi URL cũ do reset() nạp vào, ví dụ "http://res.cloudinary.com/..." "string" (không xét) Điều kiện false → bỏ qua upload → gửi nguyên URL cũ lên server ✅
Người dùng có chọn ảnh mới FileList có 1 phần tử "object" 1 Điều kiện true → upload → thay data.image bằng URL mới ✅
Bấm vào ô file rồi bấm Cancel FileList rỗng "object" 0 Điều kiện false → giữ ảnh cũ ✅

Mẫu 3 dòng này lặp y hệt ở EditCategoryPage:43-45, EditNewsPage:40-42, EditSlidePage, EditUserPage:67-69, AdminUpdateInfoPage.

⚠️ BUG NẶNG — trang sửa sản phẩm không nạp được dữ liệu: App.js:274-277 khai báo route là ":id/edit", nhưng EditProductPage.js:32 lại đọc const { slug } = useParams();. Tên tham số không khớp → slug === undefined → gọi get(undefined) → request GET /api/products/undefined → form trống trơn.

Nghịch lý: giá trị trong URL lại đúng là slug (do ListProduct.js:142 sinh ra /admin/product/${item.slug}/edit). Chỉ có tên tham số trong App.js bị đặt sai. Cách đúng: đổi App.js thành path: ":slug/edit". Cùng lỗi ở EditCategoryPage:28, EditNewsPage:27, EditCateNewsPage:25. Riêng EditSlidePage:22 và EditUserPage:50 dùng id — đúng, vì route của chúng cũng là :id và link sinh ra từ item._id.

⚠️ Bất nhất giữa thêm và sửa: AddProductPage ép price: +data.price, status: +data.status; EditProductPage gửi thẳng data nên price/status là chuỗi. May là Mongoose tự cast "1" → 1 theo schema, nhưng đây vẫn là bất nhất rõ rệt.

⚠️ EditProductPage.js:266 có <div className="error-image ..." /> rỗng, không bind errors — tàn dư của trang thêm, code chết.


7. UPLOAD ẢNH CLOUDINARY

7.1. Toàn bộ hàm uploadFile

yotea-fe/src/utils/index.js:3-12

export const uploadFile = async (fileName) => {
  const formData = new FormData();
  formData.append("file", fileName);
  formData.append("upload_preset", "kkio3wiw");
  const { data } = await axios.post(
    "https://api.cloudinary.com/v1_1/levantuan/image/upload",
    formData
  );
  return data.url;
};

Mười dòng này là toàn bộ hệ thống upload ảnh của Yotea. Không có multer, không có thư mục uploads/ trên server, backend không hề đụng vào file.

Đọc từng dòng:

Dòng Code Ý nghĩa
3 uploadFile(fileName) Tham số đặt tên nhầm — nó nhận object File, không phải tên file
4 new FormData() Tạo "phong bì" kiểu multipart/form-data
5 append("file", fileName) Nhét file vào trường file — tên trường Cloudinary quy định
6 append("upload_preset", "kkio3wiw") Nhét tên preset; đây là "vé" cho phép upload không cần chữ ký
7-10 axios.post(url, formData) axios thấy body là FormData nên tự đặt Content-Type: multipart/form-data; boundary=...
11 return data.url Trả về một chuỗi URL

7.2. FormData là gì

📖 Thuật ngữ: FormData — API sẵn có của trình duyệt, mô phỏng một biểu mẫu HTML có enctype="multipart/form-data". Khác với JSON (chỉ chở được chữ và số), FormData chở được dữ liệu nhị phân (ảnh, video, PDF).

POST /v1_1/levantuan/image/upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAbC123

------WebKitFormBoundaryAbC123
Content-Disposition: form-data; name="file"; filename="tra-sua-tran-chau.jpg"
Content-Type: image/jpeg

<...hàng trăm KB dữ liệu nhị phân của tấm ảnh...>
------WebKitFormBoundaryAbC123
Content-Disposition: form-data; name="upload_preset"

kkio3wiw
------WebKitFormBoundaryAbC123--

boundary là chuỗi ngẫu nhiên đóng vai trò "vách ngăn" giữa các phần. Bạn không được tự đặt Content-Type khi gửi FormData — nếu đặt tay, boundary sẽ mất và server không tách được các phần.

7.3. Unsigned upload là gì

Cloudinary có hai cách nhận file:

Signed upload Unsigned upload
Ai ký Server của bạn, dùng API Secret Không ai ký
Vé vào cửa Chữ ký signature + timestamp Chỉ cần tên upload preset
API Secret lộ ra trình duyệt? Không Không (vì không dùng đến)
Ai upload được? Chỉ người server bạn cho phép Bất kỳ ai biết cloud name + tên preset
Kiểm soát Chặt Lỏng — chỉ giới hạn bằng cấu hình preset
Dùng khi Ứng dụng thật, cần kiểm duyệt Đồ án, prototype, ảnh công khai

📖 Thuật ngữ: upload preset — một "hồ sơ cấu hình sẵn" trên Cloudinary: lưu vào thư mục nào, tự resize không, định dạng gì, có cho phép unsigned không. Khi bạn gửi kèm upload_preset=kkio3wiw, Cloudinary tra ra hồ sơ đó và áp dụng.

🔒 Ghi chú bảo mật: unsigned preset nghĩa là bất kỳ ai xem source trang web của bạn (Ctrl+U hoặc tab Network) đều đọc được cloud name + preset, và có thể tự viết một vòng lặp đẩy hàng nghìn ảnh rác lên tài khoản của bạn cho tới khi hết quota. Với dự án thật, cách đúng là: FE xin server một chữ ký (GET /api/cloudinary/signature), server ký bằng API Secret giữ trong .env, FE gửi file kèm chữ ký đó. Cloudinary còn cho phép giới hạn preset theo Allowed origins — hãy bật.

7.4. ⚠️ Cloud name và preset trong dự án là của người khác

⚠️ Chỗ này dự án làm chưa chuẩn — HARDCODE TÀI KHOẢN NGƯỜI KHÁC:

  • Cloud name: levantuan
  • Upload preset: kkio3wiw

Đây là tài khoản Cloudinary của tác giả gốc bài giảng mà đồ án này tham khảo, không phải của bạn. Nó bị nhúng cứng vào mã nguồn, không qua biến môi trường.

Hệ quả bạn sẽ gặp ngay khi chạy dự án hôm nay:

  1. Tài khoản đó có thể đã bị xoá, khoá, hoặc đã tắt unsigned preset → mọi lần bấm "Thêm sản phẩm" đều thất bại ở bước upload, và vì try/catch bắt hụt (mục 5.4) nên bạn chỉ thấy... không có gì xảy ra.
  2. Nếu nó còn sống, thì mọi ảnh bạn upload đang nằm trên tài khoản của người lạ — họ xoá lúc nào thì toàn bộ ảnh sản phẩm của bạn hỏng lúc đó.
  3. Ảnh mẫu (placeholder) https://res.cloudinary.com/levantuan/image/upload/v1644302455/... lặp ở 9 file cũng thuộc tài khoản này.

Bắt buộc: trước khi làm phần "Tự tay làm", bạn phải có tài khoản Cloudinary của chính mình.

7.5. Tự tạo tài khoản Cloudinary miễn phí — làm theo từng bước

Bước 1 — Đăng ký (2 phút).

  1. Mở trình duyệt, vào https://cloudinary.com/users/register_free.
  2. Điền email, mật khẩu, tên. Chọn Role = Developer, Primary interest = Programmable Media.
  3. Bấm Create account → mở hộp thư, bấm link xác nhận.

Bước 2 — Lấy Cloud name.

  1. Sau khi đăng nhập, bạn đứng ở màn Dashboard (hoặc bấm Programmable Media → Dashboard).
  2. Nhìn khối Product Environment Credentials, dòng Cloud name — ví dụ dq7xk2abc.
  3. Chép lại. Đây là thứ sẽ thay cho chữ levantuan trong URL.

💡 API Key và API Secret ở ngay dưới — đừng chép chúng vào code frontend. Unsigned upload không cần.

Bước 3 — Tạo unsigned upload preset.

  1. Bấm biểu tượng bánh răng ⚙ (Settings) ở thanh bên trái (hoặc góc trên bên phải).
  2. Chọn tab Upload → cuộn xuống mục Upload presets.
  3. Bấm Add upload preset.
  4. Upload preset name: gõ yotea_unsigned (hoặc để tên tự sinh, miễn là bạn chép lại).
  5. Signing Mode: đổi từ Signed sang Unsigned ← BƯỚC QUAN TRỌNG NHẤT.
  6. Folder: gõ yotea để mọi ảnh gom vào một thư mục cho gọn.
  7. (Tuỳ chọn) tab Upload Manipulations → Incoming transformation → đặt width 1000, crop limit để tự thu nhỏ ảnh quá to.
  8. Bấm Save ở góc trên bên phải.
  9. Quay lại danh sách preset, xác nhận dòng của bạn ghi Unsigned. Chép tên preset.

Bước 4 — Thử trước bằng terminal (không cần code).

# thay <CLOUD_NAME> và <PRESET> bằng của bạn; đứng ở thư mục nào cũng được
curl -X POST "https://api.cloudinary.com/v1_1/<CLOUD_NAME>/image/upload" \
  -F "file=@anh-thu.jpg" \
  -F "upload_preset=<PRESET>"

Nếu đúng, bạn nhận JSON dài, trong đó có:

{
  "public_id": "yotea/xyz123",
  "url": "http://res.cloudinary.com/<CLOUD_NAME>/image/upload/v1.../yotea/xyz123.jpg",
  "secure_url": "https://res.cloudinary.com/<CLOUD_NAME>/image/upload/v1.../yotea/xyz123.jpg"
}

Nếu sai preset, bạn nhận {"error":{"message":"Upload preset not found"}}. Nếu preset còn để Signed, bạn nhận {"error":{"message":"Upload preset must be whitelisted for unsigned uploads"}}.

Bước 5 — Dùng trong dự án. Ở phần "Tự tay làm" bên dưới, bạn sẽ tạo file mới yotea-fe/src/utils/cloudinary.js chứa hàm upload dùng tài khoản của bạn — không sửa utils/index.js (ta để dành việc dọn dẹp đó cho Bài 34).

7.6. ⚠️ data.url thay vì data.secure_url

Dòng cuối hàm là return data.url;. Cloudinary trả về cả hai:

Trường Giá trị
data.url http://res.cloudinary.com/... HTTP — không mã hoá
data.secure_url https://res.cloudinary.com/... HTTPS — mã hoá

⚠️ Chỗ này dự án làm chưa chuẩn: dùng data.url nghĩa là URL http:// được ghi thẳng vào database. Khi bạn deploy website lên tên miền https (Vercel/Netlify đều mặc định https), trình duyệt gặp ảnh http trong trang https sẽ coi là mixed content và chặn không tải ảnh (Chrome hiện icon ổ khoá gạch chéo, ảnh vỡ). Tệ hơn: URL đã nằm trong DB rồi, sửa code không đủ — phải chạy script cập nhật lại toàn bộ bản ghi cũ.

Cách đúng — đổi đúng một chữ:

// đoạn này bạn tự viết thêm, dự án chưa có
return data.secure_url;

8. Phân trang admin — AdminPagination và người anh em phía khách

8.1. AdminPagination.js

yotea-fe/src/components/admin/AdminPagination.js:3-20

const AdminPagination = ({ page, totalPage, url }) => {
  const pagination = [];

  for (let i = 1; i <= totalPage; i++) {
    pagination.push(
      <Link
        key={i}
        to={`/admin/${url}/page/${i}`}
        className={`${
          page === i
            ? "z-10 bg-indigo-50 border-indigo-500 text-indigo-600"
            : "bg-white border-gray-300 text-gray-500 hover:bg-gray-50"
        } relative inline-flex items-center px-4 py-2 border text-sm font-medium`}
      >
        {i}
      </Link>
    );
  }

Đọc từng dòng:

Dòng Ý nghĩa
3 Nhận đúng 3 props, không có state, không gọi API — component thuần trình bày
4-6 Dựng sẵn một mảng phần tử React bằng vòng for thường (không phải .map)
10 to={`/admin/${url}/page/${i}`} — tự chèn tiền tố /admin. Nên cha chỉ cần truyền url="product"
12 page === i — so sánh strict, nên page bắt buộc phải là số (đó là lý do cha ép Number(page))

Ba khối điều kiện ở phần return:

{page > 1 && ( <Link to={`/admin/${url}/page/${page - 1}`}> … Previous … </Link> )}   // :32-52
{totalPage > 1 && pagination}                                                          // :54
{page < totalPage && ( <Link to={`/admin/${url}/page/${page + 1}`}> … Next … </Link> )} // :56-76

8.2. So với Pagination.js phía khách

yotea-fe/src/components/user/Pagination.js:5-22

const Pagination = ({ page, totalPage, url }) => {
  const pagination = [];
  for (let i = 1; i <= totalPage; i++) {
    pagination.push(
      <li key={i}>
        <Link
          to={`/${url}/page/${i}`}
          className={`w-10 h-10 rounded-full border-2 flex items-center justify-center font-semibold mx-0.5 cursor-pointer transition ease-linear duration-200 hover:bg-[#D9A953] hover:border-[#D9A953] hover:text-white ${
            page === i
              ? "border-[#D9A953] bg-[#D9A953] text-white"
              : "border-gray-500 text-gray-500"
          }`}
        >
          {i}
        </Link>
      </li>
    );
  }

Bảng so sánh:

Tiêu chí components/admin/AdminPagination.js components/user/Pagination.js
Props { page, totalPage, url } { page, totalPage, url } — giống hệt
Thuật toán vòng for 1→totalPage giống hệt
URL sinh ra `/admin/${url}/page/${i}` — tự chèn /admin `/${url}/page/${i}` — cha truyền url đầy đủ
Thẻ bọc <div id="pagination"><nav aria-label="Pagination"> <ul className="flex justify-center mt-5">
Mỗi nút <Link> phẳng <li><Link>…</Link></li>
Icon Prev/Next SVG heroicons dán inline FontAwesome faAngleLeft/faAngleRight
Kiểu dáng chữ nhật liền khối, màu indigo tròn rounded-full, màu thương hiệu #D9A953
limit bên gọi 10 (mọi trang admin) 9 (sản phẩm) / 8 (tin tức) / 4 (bình luận)
a11y có aria-label + sr-only không có
HTML hợp lệ ✅ ❌ <button> lồng trong <Link> (:32-34, :47-49)

⚠️ Cả hai đều thiếu dấu …: nếu có 500 trang thì in đủ 500 nút, tràn màn hình. Cách đúng là chỉ hiện 1 … 4 5 [6] 7 8 … 50.

⚠️ Trùng lặp logic: hai file cùng thuật toán, khác mỗi lớp áo. Đúng ra nên là một component nhận thêm prop variant và basePath.


9. Quản lý đơn hàng

9.1. CartListPage.js — trang danh sách đặc biệt (không có "Thêm")

yotea-fe/src/pages/admin/cart/CartListPage.js:6-16

const CartListPage = () => {
  const [totalItem, setTotalItem] = useState(0);

  const { page } = useParams();

  const limit = 10;
  const totalPage = Math.ceil(totalItem / limit);
  let currentPage = Number(page) || 1;
  currentPage =
    currentPage < 1 ? 1 : currentPage > totalPage ? totalPage : currentPage;
  const start = (currentPage - 1) * limit > 0 ? (currentPage - 1) * limit : 0;

Khác ProductListPage: tổng số đơn không tự lấy được, mà con set ngược lên cha qua callback:

yotea-fe/src/pages/admin/cart/CartListPage.js:68-78

                <OrderList
                  onSetTotal={setTotalItem}
                  start={start}
                  limit={limit}
                />

                <AdminPagination
                  page={currentPage}
                  totalPage={totalPage}
                  url="cart"
                />

9.2. OrderList.js — bảng đơn hàng

yotea-fe/src/components/admin/OrderList.js:9-19

  useEffect(() => {
    const getOrders = async () => {
      const { data } = await getAll();

      onSetTotal(data.length);

      const { data: orderList } = await getAll(start, limit);
      setOrders(orderList);
    };
    getOrders();
  }, [start]);

Lại là mẫu "gọi 2 lần: một lần đếm, một lần lấy trang" — getAll() không truyền limit nên tải toàn bộ đơn hàng về chỉ để đọc .length.

Cột trạng thái, yotea-fe/src/components/admin/OrderList.js:100-118:

            <td className="px-6 py-4 whitespace-nowrap">
              <span
                className={`${
                  item.status !== 4
                    ? "bg-green-100 text-green-800"
                    : "bg-red-100 text-red-800"
                } px-2 inline-flex text-xs leading-5 font-semibold rounded-full`}
              >
                {!item.status
                  ? "Đơn hàng mới"
                  : item.status === 1
                  ? "Đã xác nhận"
                  : item.status === 2
                  ? "Đang giao hàng"
                  : item.status === 3
                  ? "Đã giao hàng"
                  : "Đã hủy"}
              </span>
            </td>

Cột Actions chỉ có một nút, yotea-fe/src/components/admin/OrderList.js:123-128:

              <Link
                to={`/admin/cart/${item._id}/detail`}
                className=/* ...class Tailwind... */
              >
                Detail
              </Link>

→ Không cho xoá đơn ở màn danh sách. Hợp lý về nghiệp vụ (đơn hàng là chứng từ, chỉ nên huỷ chứ không xoá).


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í