Bài 32 — Trang quản trị: CRUD, phân trang, upload ảnh Cloudinary Phần 2
9.3. BẢNG GIÁ TRỊ status ↔ NHÃN TRẠNG THÁI
Lấy đúng từ code — cột "nhãn ngắn" từ OrderList.js:108-116, cột "nhãn dài" từ CartDetailPage.js:127-137, cột "nút" từ CartDetailPage.js:70-108, và cột "dropdown tìm kiếm" từ CartListPage.js:53-59:
status |
Nhãn ở bảng (OrderList) |
Nhãn ở trang chi tiết (CartDetailPage) |
Nút chuyển tiếp | Nhãn trong dropdown (CartListPage) |
Màu badge |
|---|---|---|---|---|---|
0 |
Đơn hàng mới | Đang chờ xác nhận |
Xác nhận ĐH → handleUpdateStt(1) |
Đơn hàng mới | xanh |
1 |
Đã xác nhận | Đã xác nhận lúc <thời gian> |
Đang giao hàng → handleUpdateStt(2) |
Đã xác nhận | xanh |
2 |
Đang giao hàng | Đang giao hàng lúc <thời gian> |
Đã giao hàng → handleUpdateStt(3) |
Đang giao hàng | xanh |
3 |
Đã giao hàng | Đã giao thành công lúc <thời gian> |
(hết đường) | Đã giao hoàng ⚠️ sai chính tả |
xanh |
4 |
Đã hủy | Đã bị hủy lúc <thời gian> |
(hết đường) | Đã hủy | đỏ |
Nút Hủy ĐH → handleUpdateStt(4), chỉ hiện khi order.status !== 4 && order.status !== 3 (CartDetailPage.js:98).
⚠️
{!item.status ? "Đơn hàng mới" : ...}— dùng!gộp0,undefined,null,""thành một. Nếu một đơn cũ thiếu trườngstatusthì vẫn hiện "Đơn hàng mới". Cách đúng:item.status === 0 ? .... Tốt hơn nữa: tách một object hằngconst ORDER_STATUS = { 0: "Đơn hàng mới", 1: "Đã xác nhận", ... }rồi traORDER_STATUS[item.status]— thay cho chuỗi ternary lồng 5 tầng đang bị copy ở 2 nơi với 2 cách viết khác nhau.
9.4. CartDetailPage.js — đổi trạng thái đơn
yotea-fe/src/pages/admin/cart/CartDetailPage.js:34-57
const handleUpdateStt = (status) => {
Swal.fire({
title: "Bạn có chắc chắn muốn cập nhật trạng thái đơn hàng?",
text: "Bạn không thể hoàn tác sau khi cập nhật!",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
confirmButtonText: "Yes!",
}).then(async (result) => {
if (result.isConfirmed) {
setLoading(true);
const { data } = await update({
...order,
status,
});
Swal.fire("Thành công!", "Đơn hàng đã được cập nhật.", "success");
setOrder(data);
setLoading(false);
}
});
};
Đường đi đầy đủ khi admin bấm "Xác nhận ĐH":
Bấm nút → handleUpdateStt(1)
→ Swal.fire xác nhận
→ update({ ...order, status: 1 }) [api/order.js:33-40]
└─ PUT /api/orders/<orderId>/<adminId> + header Authorization: Bearer <token>
└─ routes/order.js:11 requireSignin → isAuth → update
└─ controllers/order.js update()
└─ Order.findOneAndUpdate(...) → MongoDB collection orders
← res.json(orderMoi)
→ setOrder(data) → React vẽ lại nhãn + đổi nút sang bước kế tiếp
⚠️ Chỗ này dự án làm chưa chuẩn — 4 vấn đề trong 20 dòng:
update({ ...order, status })gửi nguyên cả object đơn hàng lên (kèm_id,createdAt,userId,totalPrice…). Chỉ cần đổistatusmà gửi cả cục → nếu client sửatotalPricetrước khi gửi thì server ghi đè luôn. Cách đúng: chỉ gửi{ status }, hoặc dùngPATCH.await update(...)không cótry/catch. API lỗi → promise reject →setLoading(false)ở dòng 54 không bao giờ chạy → vòng xoay loading (<Loading active={loading} />,:267) quay vĩnh viễn, phải F5.Swal.fire("Thành công!")ở dòng 52 chạy trước khi kiểm tra kết quả — trên lý thuyết là sauawaitnên OK, nhưng nếu API 4xx thì cả dòng này lẫn 2 dòng sau đều bị bỏ qua và không có thông báo lỗi nào.- Không có transition guard ở server: UI chỉ ẩn nút, còn API cho phép nhảy từ
0thẳng lên3, hoặc từ4(đã huỷ) về1. Ai gọi thẳng API là làm được. Cách đúng: kiểm tra chuyển trạng thái hợp lệ trong controller.⚠️
CartDetailPage.js:18const { user } = useSelector(selectAuth);— khai báo rồi không dùng ở đâu → biến chết.🔒 Ghi chú bảo mật:
routes/order.js:8-12cho thấyPOST /orders,GET /orders,GET /orders/:id,DELETE /orders/:idhoàn toàn không có middleware. Ai cũng gọi đượcGET http://localhost:8080/api/ordersđể lấy toàn bộ tên, số điện thoại, email, địa chỉ khách hàng, vàDELETEđể xoá đơn bất kỳ. ChỉPUTlà córequireSignin, isAuth. Chi tiết ở Bài 33.
10. Kiểm duyệt bình luận — AdminCommentList.js
yotea-fe/src/components/admin/AdminCommentList.js:11-20
useEffect(() => {
const getComment = async () => {
const { data } = await get(id);
onSetTotal(data.length);
const { data: listComment } = await get(id, start, limit);
setComments(listComment);
};
getComment();
}, [start]);
Hàm xoá, yotea-fe/src/components/admin/AdminCommentList.js:22-42:
const handleRemoveCmt = 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) {
remove(id)
.then(() => {
Swal.fire("Thành công!", "Đã xóa thành công.", "success");
})
.then(() =>
setComments((prev) => prev?.filter((item) => item._id !== id))
);
}
});
};
Bảng hiển thị 5 cột: STT, Người bình luận (avatar + fullName + email), Nội dung, Thời gian, nút Delete.
☠️ MÀN NÀY LÀ CODE CHẾT. Không file nào import
AdminCommentList. Không có route/admin/comment. Nghĩa là chức năng kiểm duyệt bình luận chưa tồn tại trong ứng dụng — bạn không có cách nào bấm vào nó từ giao diện.⚠️ Dấu hiệu dở dang rõ nhất:
:9const { id } = useParams();rồi:13get(id)—getcủaapi/comment.js:15nhậnproductId. Tức component này được thiết kế để nằm trong một route có:idlà id sản phẩm, kiểu/admin/product/:id/comments. Route đó chưa bao giờ được tạo.⚠️
:96item.userId.avatarkhông optional chaining → nếu một bình luận mồ côi (user đã bị xoá) thì crash cả bảng. Cách đúng:item.userId?.avatar.⚠️
remove(id)không.catch(); và.then(...).then(...)xâu chuỗi kiểu này khiến việc lọc mảng chạy kể cả khi người dùng bấm OK trên hộp thoại "Thành công" — trông thì đúng, nhưng nếu API lỗi thì cả hai.thenđều bị bỏ qua và hộp thoại thành công cũng không hiện → im lặng tuyệt đối.
11. Quản lý liên hệ
11.1. ContactListPage.js — lấy tổng từ Redux
yotea-fe/src/pages/admin/contact/ContactListPage.js:7-17
const ContactListPage = () => {
const totalItem = useSelector(selectTotalContact);
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;
Đây là cách thứ ba để lấy tổng số bản ghi trong cùng một dự án:
| Cách | Ai dùng | Cơ chế |
|---|---|---|
| A | ProductListPage |
RTK Query useGetProductsQuery({}) rồi .length |
| B | CartListPage |
con gọi callback onSetTotal set state cho cha |
| C | ContactListPage, NewsListPage, UserListPage |
thunk lưu totalContact vào Redux, cha useSelector |
Cách C nằm ở yotea-fe/src/redux/contactSlice.js:9-18:
export const getContacts = createAsyncThunk(
"contact/getContacts",
async ({ start, limit }) => {
const { data } = await getAll();
const totalContact = data.length;
const { data: dataContact } = await getAll(start, limit);
return { totalContact, dataContact };
}
);
Vẫn là 2 request, chỉ khác chỗ chứa kết quả.
11.2. ContactDetailPage.js — form chỉ để xem
yotea-fe/src/pages/admin/contact/ContactDetailPage.js:11-17
useEffect(() => {
const getContact = async () => {
const { data } = await get(id);
setContact({ ...data, createdAt: formatDate(data.createdAt) });
};
getContact();
}, []);
Mọi ô đều disabled + defaultValue, ví dụ yotea-fe/src/pages/admin/contact/ContactDetailPage.js:54-59:
<input
type="text"
disabled
className=/* ...class Tailwind... */
defaultValue={contact?.name}
/>
⚠️ BUG NẶNG — form này luôn rỗng.
defaultValuetrên input uncontrolled chỉ được React áp dụng ở lần render đầu tiên. Lúc đócontactcòn làundefined(dữ liệu về sau, bất đồng bộ). KhisetContactchạy và component render lại, React không đụng vàovaluecủa DOM nữa → mọi ô vẫn trắng.Cách đúng (3 lựa chọn):
- Dùng controlled:
value={contact?.name ?? ""} readOnly.- Ép remount cả form: thêm
key={contact?._id}lên thẻ cha.- Chỉ hiện khi có dữ liệu:
{contact && ( <form>…</form> )}.⚠️
:100defaultValue={contact?.store.name}— optional chaining nửa vời. Nếucontactcó nhưngstorechưa được populate thìundefined.name→ TypeError, trắng màn. Phải làcontact?.store?.name. Lỗi y hệt ởContactList.js:113—{item.store.name}không có?.nào, đủ để làm sập cả bảng danh sách.⚠️
:110-114ô "Ngày gửi" cóid="a"(id vô nghĩa) và quêndisabledtrong khi 5 ô còn lại đều có → admin gõ được vào ô này (dù không lưu đi đâu).⚠️
:40bọc tất cả trong<form action="" method="POST">dù không submit gì. Bấm Enter trong ô "Ngày gửi" sẽ khiến trình duyệt reload trang.
12. ⚠️ Dự án trộn 3 phong cách lấy dữ liệu trong cùng khu admin
Đây là điều gây bối rối nhất khi bạn đọc code khu /admin lần đầu:
| Màn | Cách lấy dữ liệu | Nơi khai báo |
|---|---|---|
| Sản phẩm (list/add/edit/delete) | RTK Query useGetProductsQuery, useAddProductMutation, useUpdateProductMutation, useDeleteProductMutation |
api/product.js:96-147 |
| Danh mục SP, Tin tức, Danh mục tin, Slider, User, Liên hệ | createAsyncThunk + slice | redux/*.js |
| Đơn hàng, Chi tiết liên hệ, Bình luận | gọi axios trực tiếp trong useEffect + useState |
api/order.js, api/contact.js, api/comment.js |
⚠️ Chỗ này dự án làm chưa chuẩn: ba phong cách cùng tồn tại. Hệ quả thực tế:
- Xoá một sản phẩm → RTK Query
invalidatesTags: ["Product"]tự nạp lại bảng. ✅- Xoá một liên hệ → slice tự lọc mảng trong reducer. ✅ nhưng phải viết tay từng case.
- Xoá một bình luận → tự
setComments(prev => prev.filter(...)). ✅ nhưng nếu có 2 component cùng hiển thị bình luận thì component kia không biết và vẫn hiện dòng đã xoá.Cách đúng: chọn một phong cách cho cả dự án. Với dữ liệu server thuần CRUD như thế này, RTK Query là lựa chọn gọn nhất (xem lại Bài 22).
⚠️ Và cụ thể ở
ProductListPage.js:7:useGetProductsQuery({})— truyền object rỗng, không cóstart/limit. EndpointgetProducts(api/product.js:104-106) cólimit = 0mặc định vàif (limit) url += ...→ điều kiện false → URL không kèm phân trang → tải hết. Trang này cố ý làm vậy để đếm tổng, nhưng nó vô tình dạy sai: nhiều bạn copy dòng đó sang chỗ khác rồi thắc mắc "sao phân trang không ăn".
13. 🛠️ Tự tay làm — 3 màn quản trị Topping
Mục tiêu phần này: cuối phần bạn vào được
http://localhost:3000/admin/topping, thấy bảng topping có phân trang, bấm Thêm topping để tạo mới kèm ảnh upload lên tài khoản Cloudinary của chính bạn, bấm Edit để sửa mà không cần chọn lại ảnh, bấm Delete để xoá có xác nhận. Và có một mục Toppings trong sidebar.Đây là bài tổng duyệt: bạn sẽ dùng lại đúng khuôn mẫu ở mục 4 — nhưng làm đúng ở những chỗ dự án làm sai.
Những gì bạn đã có từ các bài trước
| Từ bài | Bạn đã có |
|---|---|
| 05, 07, 08, 09, 12, 13 | yotea-be/src/models/topping.js, routes/topping.js, controllers/topping.js — CRUD + slug + bộ lọc + isAdmin + swagger |
| 18 | yotea-fe/src/api/topping.js — getAll / get / add / update / remove |
| 20 | yotea-fe/src/redux/toppingSlice.js |
| 22 | RTK Query trong api/topping.js |
| 15, 25 | Route /topping và ToppingPage, ToppingCard phía khách |
Bài này giả định api/topping.js của bạn export những thứ sau. Nếu bạn đặt tên khác, đổi lại cho khớp:
// yotea-fe/src/api/topping.js — bạn đã viết ở bài 18 & 22
export const getAll = (start = 0, limit = 0) => { ... }; // GET /api/toppings
export const get = (slug) => { ... }; // GET /api/toppings/:slug
export const {
useGetToppingsQuery,
useAddToppingMutation,
useUpdateToppingMutation,
useDeleteToppingMutation,
} = toppingApi;
Và model Topping có các trường: name, price, image, slug, status, timestamps.
Nếu model của bạn chưa có image hoặc status, hãy thêm vào trước khi làm tiếp — bài này cần cả hai.
Bước 0 — Tạo helper upload của riêng bạn
Bạn đã có cloud name + preset ở mục 7.5. Tạo file MỚI (không sửa utils/index.js):
// yotea-fe/src/utils/cloudinary.js ← file MỚI, bạn tự tạo
import axios from "axios";
// TODO: thay 2 hằng số này bằng của TÀI KHOẢN CLOUDINARY CỦA BẠN
const CLOUD_NAME = "dq7xk2abc"; // Dashboard → Cloud name
const UPLOAD_PRESET = "yotea_unsigned"; // Settings → Upload → Upload presets (Unsigned)
export const uploadImage = async (file) => {
const formData = new FormData();
formData.append("file", file);
formData.append("upload_preset", UPLOAD_PRESET);
const { data } = await axios.post(
`https://api.cloudinary.com/v1_1/${CLOUD_NAME}/image/upload`,
formData
);
// KHÁC dự án: dùng secure_url (https) thay vì url (http)
return data.secure_url;
};
💡 Ba khác biệt so với
utils/index.js: (1) cloud name/preset là của bạn, (2) đưa lên hằng số có tên rõ ràng thay vì nhét thẳng vào chuỗi, (3) trảsecure_url. Ở Bài 34 ta sẽ đẩy nốt 2 hằng số này ra file.env.
Bước 1 — ToppingListPage.js
// yotea-fe/src/pages/admin/topping/ToppingListPage.js ← file MỚI, bạn tự tạo
import { Link, useParams } from "react-router-dom";
import Swal from "sweetalert2";
import { toast } from "react-toastify";
import {
useGetToppingsQuery,
useDeleteToppingMutation,
} from "../../../api/topping";
import AdminPagination from "../../../components/admin/AdminPagination";
import { formatCurrency } from "../../../utils";
const ToppingListPage = () => {
const { page } = useParams();
// Lấy TẤT CẢ để đếm tổng (giống dự án) — biết là chưa tối ưu, xem ghi chú bên dưới
const { data: allToppings } = useGetToppingsQuery({});
const totalItem = allToppings?.length || 0;
const limit = 10;
// KHÁC dự án: Math.max(1, ...) để không bao giờ ra totalPage = 0
const totalPage = Math.max(1, Math.ceil(totalItem / limit));
let currentPage = Number(page) || 1;
currentPage =
currentPage < 1 ? 1 : currentPage > totalPage ? totalPage : currentPage;
const start = (currentPage - 1) * limit;
const { data: toppings, isLoading } = useGetToppingsQuery({ start, limit });
const [deleteTopping] = useDeleteToppingMutation();
const handleRemove = (id) => {
Swal.fire({
title: "Bạn có chắc chắn muốn xóa topping này?",
text: "Bạn không thể hoàn tác sau khi xóa!",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
confirmButtonText: "Xóa",
cancelButtonText: "Hủy",
}).then((result) => {
if (result.isConfirmed) {
deleteTopping(id)
.unwrap()
.then(() => Swal.fire("Thành công!", "Đã xóa topping.", "success"))
// KHÁC dự án: CÓ .catch() nên xoá lỗi là biết ngay
.catch(() => toast.error("Xóa thất bại, vui lòng thử lại"));
}
});
};
return (
<>
<header className="z-10 fixed top-14 left-0 md:left-60 right-0 px-4 py-1.5 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.1)] flex items-center justify-between">
<div className="flex items-center text-sm text-gray-600">
<h5 className="pr-5">Toppings</h5>
<span>DS topping</span>
</div>
<Link to="/admin/topping/add">
<button
type="button"
className="inline-flex items-center px-2 py-1 rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
Thêm topping
</button>
</Link>
</header>
<div className="p-6 mt-24 overflow-hidden">
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{["STT", "Ảnh", "Tên topping", "Giá", "Trạng thái", "Actions"].map((h) => (
<th key={h} className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
{h}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{isLoading && (
<tr>
<td colSpan={6} className="px-6 py-4 text-sm text-gray-500">
Đang tải...
</td>
</tr>
)}
{toppings?.map((item, index) => (
// KHÁC dự án: key là _id, và STT không mutate index
<tr key={item._id}>
<td className="px-6 py-4 text-sm text-gray-500">
{index + 1 + start}
</td>
<td className="px-6 py-4">
<img
src={item.image}
alt={item.name}
className="h-10 w-10 rounded-full object-cover"
/>
</td>
<td className="px-6 py-4 text-sm font-medium text-gray-900">
{item.name}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatCurrency(item.price || 0)}
</td>
<td className="px-6 py-4">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
item.status
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{item.status ? "Hiển thị" : "Ẩn"}
</span>
</td>
<td className="px-6 py-4 text-sm font-medium">
<Link
to={`/admin/topping/${item.slug}/edit`}
className="h-8 inline-flex items-center px-3 rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700"
>
Edit
</Link>
<button
onClick={() => handleRemove(item._id)}
className="h-8 inline-flex items-center px-3 ml-3 rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
<AdminPagination
page={currentPage}
totalPage={totalPage}
url="topping"
/>
</div>
</div>
</>
);
};
export default ToppingListPage;
💡 Chú ý dòng
to={`/admin/topping/${item.slug}/edit`}— nút Edit sinh slug. Ở Bước 5 ta sẽ khai báo route là:slug/edit(không phải:id/editnhư dự án) để tránh đúng cái bug ở mục 6.3.
Bước 2 — AddToppingPage.js
// yotea-fe/src/pages/admin/topping/AddToppingPage.js ← file MỚI, bạn tự tạo
import { useState } from "react";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { Link } from "react-router-dom";
import { toast } from "react-toastify";
import { useAddToppingMutation } from "../../../api/topping";
import { uploadImage } from "../../../utils/cloudinary";
const schema = yup.object().shape({
name: yup.string().required("Vui lòng nhập tên topping"),
price: yup
.number()
.typeError("Giá phải là số") // KHÁC dự án: chặn được chữ và ô rỗng
.min(0, "Giá không được âm")
.required("Vui lòng nhập giá topping"),
image: yup
.mixed()
.test("require", "Vui lòng chọn ảnh topping", (value) => value?.length > 0),
status: yup
.number()
.typeError("Vui lòng chọn trạng thái") // KHÁC dự án: chặn được option rỗng
.required("Vui lòng chọn trạng thái"),
});
const AddToppingPage = () => {
const [preview, setPreview] = useState("");
const [loading, setLoading] = useState(false);
const [addTopping] = useAddToppingMutation();
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm({
resolver: yupResolver(schema),
defaultValues: { status: 1 }, // KHÁC dự án: defaultValues thay vì defaultValue trên <select>
});
// tách onChange của RHF ra để không bị ghi đè
const { onChange: rhfOnChangeImage, ...imageField } = register("image");
const handlePreview = (e) => {
const file = e.target.files?.[0];
if (!file) return;
setPreview((old) => {
if (old) URL.revokeObjectURL(old); // KHÁC dự án: dọn blob cũ, không rò rỉ bộ nhớ
return URL.createObjectURL(file);
});
};
const onSubmit = async (data) => {
setLoading(true);
try {
const image = await uploadImage(data.image[0]); // ① lên Cloudinary CỦA BẠN
await addTopping({ ...data, image }).unwrap(); // ② POST về backend Yotea
toast.success("Thêm topping thành công");
reset();
setPreview("");
} catch (error) {
// KHÁC dự án: await + try/catch nên MỌI lỗi đều rơi vào đây
toast.error(
error?.data?.message || "Có lỗi xảy ra, vui lòng thử lại"
);
} finally {
setLoading(false);
}
};
return (
<>
<header className="z-10 fixed top-14 left-0 md:left-60 right-0 px-4 py-1.5 bg-white shadow flex items-center justify-between">
<div className="flex items-center text-sm text-gray-600">
<h5 className="pr-5">Toppings</h5>
<span>Thêm topping</span>
</div>
<Link to="/admin/topping">
<button className="px-2 py-1 rounded-md text-sm text-white bg-indigo-600">
DS topping
</button>
</Link>
</header>
<div className="p-6 mt-24">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="shadow sm:rounded-md bg-white p-6 grid grid-cols-6 gap-6">
<div className="col-span-6">
<label className="block text-sm font-medium text-gray-700">Tên topping</label>
<input
type="text"
{...register("name")}
placeholder="VD: Trân châu đen"
className="py-2 px-3 mt-1 border block w-full rounded-md border-gray-300"
/>
<div className="text-sm mt-0.5 text-red-500">{errors.name?.message}</div>
</div>
<div className="col-span-6 md:col-span-3">
<label className="block text-sm font-medium text-gray-700">Giá (VNĐ)</label>
<input
type="number"
{...register("price")}
placeholder="VD: 8000"
className="py-2 px-3 mt-1 border block w-full rounded-md border-gray-300"
/>
<div className="text-sm mt-0.5 text-red-500">{errors.price?.message}</div>
</div>
<div className="col-span-6 md:col-span-3">
<label className="block text-sm font-medium text-gray-700">Trạng thái</label>
<select
{...register("status")}
className="mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md"
>
<option value="">-- Chọn trạng thái --</option>
<option value={0}>Ẩn</option>
<option value={1}>Hiển thị</option>
</select>
<div className="text-sm mt-0.5 text-red-500">{errors.status?.message}</div>
</div>
<div className="col-span-6 md:col-span-3">
<label className="block text-sm font-medium text-gray-700">Xem trước ảnh</label>
<img
src={preview || "https://placehold.co/400x300?text=Topping"}
alt="Xem truoc anh topping"
className="mt-1 h-48 w-full object-cover rounded-md border"
/>
</div>
<div className="col-span-6">
<label className="block text-sm font-medium text-gray-700">Ảnh topping</label>
<input
type="file"
accept="image/*" {/* KHÁC dự án: chỉ cho chọn ảnh */}
{...imageField}
onChange={(e) => {
rhfOnChangeImage(e); // để RHF/yup biết đã có file
handlePreview(e); // rồi mới hiện preview
}}
className="mt-1 block w-full text-sm"
/>
<div className="text-sm mt-0.5 text-red-500">{errors.image?.message}</div>
</div>
<div className="col-span-6 text-right">
<button
type="submit"
disabled={loading} {/* KHÁC dự án: chặn double-click */}
className="py-2 px-4 rounded-md text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50"
>
{loading ? "Đang lưu..." : "Thêm topping"}
</button>
</div>
</div>
</form>
</div>
</>
);
};
export default AddToppingPage;
Bước 3 — EditToppingPage.js
// yotea-fe/src/pages/admin/topping/EditToppingPage.js ← file MỚI, bạn tự tạo
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import { get, useUpdateToppingMutation } from "../../../api/topping";
import { uploadImage } from "../../../utils/cloudinary";
// GIỐNG AddToppingPage nhưng BỎ trường `image`
// → cho phép sửa mà không cần chọn lại ảnh
const schema = yup.object().shape({
name: yup.string().required("Vui lòng nhập tên topping"),
price: yup
.number()
.typeError("Giá phải là số")
.min(0, "Giá không được âm")
.required("Vui lòng nhập giá topping"),
status: yup
.number()
.typeError("Vui lòng chọn trạng thái")
.required("Vui lòng chọn trạng thái"),
});
const EditToppingPage = () => {
const { slug } = useParams(); // khớp với route ":slug/edit" ở Bước 5
const navigate = useNavigate();
const [preview, setPreview] = useState("");
const [loading, setLoading] = useState(true);
const [updateTopping] = useUpdateToppingMutation();
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm({ resolver: yupResolver(schema) });
const { onChange: rhfOnChangeImage, ...imageField } = register("image");
useEffect(() => {
const getTopping = async () => {
try {
const { data } = await get(slug);
setPreview(data.image);
reset(data); // ← nạp toàn bộ dữ liệu cũ vào form
} catch (error) {
toast.error("Không tải được topping này");
} finally {
setLoading(false);
}
};
getTopping();
}, [slug, reset]); // KHÁC dự án: khai đủ deps, không để [] rỗng
const handlePreview = (e) => {
const file = e.target.files?.[0];
if (!file) return;
setPreview((old) => {
if (old?.startsWith("blob:")) URL.revokeObjectURL(old);
return URL.createObjectURL(file);
});
};
const onSubmit = async (data) => {
setLoading(true);
try {
// ĐÂY LÀ TRÁI TIM CỦA TRANG SỬA:
// - không chọn ảnh mới → data.image là CHUỖI url cũ → bỏ qua upload
// - có chọn ảnh mới → data.image là FileList → upload lấy url mới
if (typeof data.image === "object" && data.image?.length) {
data.image = await uploadImage(data.image[0]);
}
await updateTopping(data).unwrap();
toast.success("Cập nhật topping thành công");
navigate("/admin/topping");
} catch (error) {
toast.error(error?.data?.message || "Có lỗi xảy ra, vui lòng thử lại");
} finally {
setLoading(false);
}
};
return (
<>
<header className="z-10 fixed top-14 left-0 md:left-60 right-0 px-4 py-1.5 bg-white shadow flex items-center justify-between">
<div className="flex items-center text-sm text-gray-600">
<h5 className="pr-5">Toppings</h5>
<span>Cập nhật topping</span> {/* nhớ: KHÔNG copy nhầm chữ "Thêm" như dự án */}
</div>
<Link to="/admin/topping">
<button className="px-2 py-1 rounded-md text-sm text-white bg-indigo-600">
DS topping
</button>
</Link>
</header>
<div className="p-6 mt-24">
{/* phần form giống hệt AddToppingPage, chỉ đổi chữ nút thành "Cập nhật topping"
và ảnh preview lấy từ state `preview` đã được set bằng data.image */}
<form onSubmit={handleSubmit(onSubmit)}>
{/* ...copy y nguyên khối grid 6 cột từ AddToppingPage... */}
<div className="col-span-6 text-right">
<button
type="submit"
disabled={loading}
className="py-2 px-4 rounded-md text-white bg-indigo-600 disabled:opacity-50"
>
{loading ? "Đang lưu..." : "Cập nhật topping"}
</button>
</div>
</form>
</div>
</>
);
};
export default EditToppingPage;
Bước 4 — Thêm route vào App.js
Mở yotea-fe/src/App.js. Thêm 3 dòng import cạnh các import admin khác (khoảng dòng 56):
// yotea-fe/src/App.js — bạn tự thêm
import ToppingListPage from "./pages/admin/topping/ToppingListPage";
import AddToppingPage from "./pages/admin/topping/AddToppingPage";
import EditToppingPage from "./pages/admin/topping/EditToppingPage";
Rồi thêm một phần tử vào mảng children của route /admin — đặt ngay sau khối product (kết thúc ở dòng 279):
// yotea-fe/src/App.js — bạn tự thêm, đặt trong children của "/admin"
{
path: "topping",
children: [
{
path: "",
element: <ToppingListPage />,
},
{
path: "page/:page",
element: <ToppingListPage />,
},
{
path: "add",
element: <AddToppingPage />,
},
{
// KHÁC dự án: dùng :slug cho khớp với useParams().slug
path: ":slug/edit",
element: <EditToppingPage />,
},
],
},
Bước 5 — Thêm mục menu vào AdminLayout.js
Mở yotea-fe/src/pages/layouts/AdminLayout.js. Thêm faCookieBite vào khối import icon (dòng 2-11):
// yotea-fe/src/pages/layouts/AdminLayout.js — bạn tự thêm vào danh sách import sẵn có
import {
faCookieBite, // ← thêm dòng này
faComment,
faNewspaper,
...
} from "@fortawesome/free-solid-svg-icons";
Rồi chèn <NavLink> mới ngay sau mục Categories Products (kết thúc ở dòng 123):
// yotea-fe/src/pages/layouts/AdminLayout.js — bạn tự thêm
<NavLink
to="/admin/topping"
className="sidebar__item flex items-center justify-between px-4 py-3 transition cursor-pointer group hover:bg-gray-800 hover:text-gray-200"
>
<div className="flex items-center">
<div className="shrink-0 w-5 h-5 mr-2 text-gray-300 transition group-hover:text-gray-300">
<FontAwesomeIcon icon={faCookieBite} />
</div>
<span>Toppings</span>
</div>
</NavLink>
14. ✅ Kiểm chứng kết quả
Chuẩn bị:
# terminal 1 — đứng tại thư mục yotea-be
npm start
# terminal 2 — đứng tại thư mục yotea-fe
npm start
Đăng nhập bằng tài khoản admin (role = 1, active = 1).
Checklist 8 điểm — làm lần lượt:
| # | Việc | Kết quả PHẢI thấy |
|---|---|---|
| 1 | Mở http://localhost:3000/admin |
Sidebar tối màu 10 mục (đã có Toppings), nội dung chỉ có chữ Dashboard |
| 2 | Bấm Toppings | Bảng topping; nếu chưa có dữ liệu thì bảng trống, không lỗi đỏ trong Console |
| 3 | Bấm Thêm topping, bỏ trống hết, bấm Submit | 4 dòng chữ đỏ hiện dưới 4 ô. Không có request nào trong tab Network |
| 4 | Điền Trân châu đen, giá 8000, chọn ảnh, trạng thái Hiển thị, Submit |
Tab Network: request 1 tới api.cloudinary.com trả 200 kèm JSON có secure_url; request 2 POST /api/toppings/<userId> trả 200. Toast xanh "Thêm topping thành công", form trắng trở lại |
| 5 | Quay lại danh sách | Dòng mới xuất hiện với ảnh đã upload hiện đúng |
| 6 | Mở Cloudinary → Media Library → thư mục yotea |
Thấy đúng tấm ảnh vừa upload |
| 7 | Bấm Edit, chỉ sửa tên thành Trân châu đen size L, không chọn ảnh, Submit |
Tab Network: chỉ có 1 request PUT /api/toppings/... — không có request Cloudinary. Ảnh trong bảng giữ nguyên |
| 8 | Bấm Delete → chọn "Xóa" | Hộp SweetAlert2 xác nhận → dòng biến mất khỏi bảng ngay lập tức (RTK Query invalidatesTags tự nạp lại) |
Kiểm tra bằng API (không qua giao diện):
curl "http://localhost:8080/api/toppings?_sort=createdAt&_order=desc&_start=0&_limit=10"
Phải nhận về mảng, trong đó có bản ghi của bạn:
[
{
"_id": "66be1a2f5c9d3e0012ab34cd",
"name": "Trân châu đen size L",
"price": 8000,
"image": "https://res.cloudinary.com/dq7xk2abc/image/upload/v1723.../yotea/abc123.jpg",
"slug": "Tran-chau-den-size-L",
"status": 1,
"createdAt": "2026-08-15T09:12:33.123Z",
"updatedAt": "2026-08-15T09:20:11.456Z"
}
]
✅ Nhìn vào
image: phải bắt đầu bằnghttps://và chứa cloud name của bạn, không phảilevantuan. Nếu vẫn thấylevantuannghĩa là bạn đang gọi nhầmuploadFilecũ.
15. 🐞 Lỗi thường gặp
| Thông báo lỗi | Nguyên nhân | Cách sửa |
|---|---|---|
{"error":{"message":"Upload preset not found"}} (400 từ Cloudinary) |
Sai tên preset, hoặc preset thuộc cloud name khác | Kiểm tra lại Settings → Upload → Upload presets; tên preset phân biệt hoa/thường |
Upload preset must be whitelisted for unsigned uploads |
Preset đang để Signed | Sửa preset → Signing Mode → Unsigned → Save |
Bấm "Thêm" mà không có gì xảy ra, Console báo Unhandled promise rejection |
try/catch bắt hụt vì thiếu await trước .unwrap() |
Dùng await addTopping(...).unwrap() như Bước 2 |
Cannot read properties of undefined (reading 'length') trong resolver |
yup.mixed().test(..., v => v.length) gặp undefined |
Đổi thành (v) => v?.length > 0 |
| Trang Edit hiện form trống trơn | Tên tham số route không khớp useParams() (bug kinh điển của dự án) |
Route phải là :slug/edit nếu code đọc const { slug } = useParams() |
<select> không tự chọn đúng danh mục cũ |
reset() nạp cả object đã populate thay vì _id |
reset({ ...data, categoryId: data.categoryId?._id }) |
| Ảnh vỡ sau khi deploy lên https | Backend lưu URL http:// (data.url) → mixed content bị chặn |
Đổi sang data.secure_url; chạy script cập nhật các bản ghi cũ |
401 Unauthorized khi POST/PUT/DELETE topping |
Thiếu header Authorization, hoặc token hết hạn, hoặc user không phải admin |
Đăng xuất/đăng nhập lại; kiểm tra role === 1 trong DB |
| Bấm Profile trong menu avatar → trang trắng | Link chết /admin/profile (mục 2.4) |
Thêm route hoặc bỏ mục menu |
TypeError: Cannot read properties of undefined (reading 'name') ở màn Liên hệ |
item.store.name thiếu ?. (mục 11.2) |
item.store?.name |
| Bảng đơn hàng trống nhưng API có dữ liệu | getAll() của api/order.js sắp xếp theo createdAt; đơn cũ thiếu trường này |
Kiểm tra timestamps: true trong models/order.js |
| Sửa xong bảng không cập nhật | Dùng thunk mà quên viết case trong extraReducers; hoặc RTK Query thiếu invalidatesTags |
Thêm invalidatesTags: ["Topping"] vào mutation |
16. 📝 Bài tập
Bài 1. Trang ToppingListPage hiện đang gọi API hai lần mỗi lần vào trang (một lần đếm tổng, một lần lấy trang), giống hệt anti-pattern của dự án. Hãy sửa backend để GET /api/toppings trả về tổng số bản ghi trong header X-Total-Count, rồi sửa frontend để chỉ còn một request.
Backend — trong yotea-be/src/controllers/topping.js, hàm list(), thêm 2 dòng trước khi trả về (đoạn này bạn tự viết thêm, dự án chưa có):
// yotea-be/src/controllers/topping.js — hàm list()
const total = await Topping.countDocuments(filter); // đếm theo ĐÚNG bộ lọc, chưa phân trang
res.set("Access-Control-Expose-Headers", "X-Total-Count"); // bắt buộc, xem giải thích dưới
res.set("X-Total-Count", total);
res.json(toppings);
💡 Vì sao cần
Access-Control-Expose-Headers? Đây là request cross-origin (3000 → 8080). Mặc định trình duyệt chỉ cho JavaScript đọc 7 header "an toàn"; header tự chế nhưX-Total-Countbị giấu đi trừ khi server khai báo cho phép lộ.
Frontend — RTK Query cho phép biến đổi response bằng transformResponse:
// yotea-fe/src/api/topping.js — bạn sửa endpoint getToppings
getToppings: builder.query({
query: ({ start = 0, limit = 0 }) => {
let url = `/toppings/?_sort=createdAt&_order=desc`;
if (limit) url += `&_start=${start}&_limit=${limit}`;
return url;
},
transformResponse: (response, meta) => ({
items: response,
total: Number(meta?.response?.headers.get("X-Total-Count")) || 0,
}),
providesTags: ["Topping"],
}),
Rồi trong ToppingListPage, xoá hẳn lời gọi useGetToppingsQuery({}) và dùng:
const { data, isLoading } = useGetToppingsQuery({ start, limit });
const toppings = data?.items;
const totalItem = data?.total || 0;
⚠️ Có một cái bẫy: start được tính từ totalPage, mà totalPage lại đến từ request. Vòng
lặp phụ thuộc này giải bằng cách: lần render đầu totalItem = 0 → currentPage = 1 → start = 0 →
request trang 1 → có total → totalPage đúng → nếu URL yêu cầu trang 3 thì render lại với
start = 20. Tức là vẫn 2 request khi vào thẳng trang 3, nhưng chỉ 1 request ở trang 1. Muốn
triệt để 1 request thì đừng clamp currentPage theo totalPage, mà để backend tự trả mảng rỗng khi
_start vượt quá — rồi hiện thông báo "Trang không tồn tại".
Bài 2. Dashboard.js đang là component rỗng. Hãy biến nó thành trang thống kê hiển thị 4 con số: Tổng đơn hàng, Doanh thu (chỉ tính đơn đã giao, status === 3), Tổng sản phẩm, Tổng khách hàng. Nêu rõ trong code những chỗ bạn biết là chưa tối ưu.
Toàn bộ file mới (đoạn này bạn tự viết thêm, dự án chưa có):
// yotea-fe/src/pages/admin/Dashboard.js — bạn viết đè lên 7 dòng cũ
import { useEffect, useState } from "react";
import { getAll as getOrders } from "../../api/order";
import { getAll as getProducts } from "../../api/product";
import { getAll as getUsers } from "../../api/user";
import { formatCurrency } from "../../utils";
const Card = ({ label, value }) => (
<div className="bg-white rounded-lg shadow p-5">
<p className="text-sm text-gray-500">{label}</p>
<p className="text-2xl font-semibold text-gray-900 mt-1">{value}</p>
</div>
);
const Dashboard = () => {
const [stats, setStats] = useState({
totalOrder: 0,
revenue: 0,
totalProduct: 0,
totalUser: 0,
});
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
try {
// ⚠️ CHƯA TỐI ƯU: backend không có API thống kê, nên phải tải
// TOÀN BỘ 3 bảng về trình duyệt rồi tự cộng. Với vài nghìn bản ghi
// là chậm thấy rõ. Cách đúng: viết endpoint GET /api/stats dùng
// aggregation pipeline của MongoDB ($match + $group + $sum).
const [{ data: orders }, { data: products }, { data: users }] =
await Promise.all([getOrders(), getProducts(), getUsers()]);
const revenue = orders
.filter((o) => o.status === 3) // chỉ đơn ĐÃ GIAO mới tính là doanh thu
.reduce(
(sum, o) =>
sum + Math.max(0, (o.totalPrice || 0) - (o.priceDecrease || 0)),
0
);
setStats({
totalOrder: orders.length,
revenue,
totalProduct: products.length,
totalUser: users.length,
});
} catch (error) {
console.error("Không tải được thống kê", error);
} finally {
setLoading(false);
}
};
load();
}, []);
if (loading) return <div className="p-6 mt-24">Đang tải thống kê...</div>;
return (
<div className="p-6 mt-24 grid grid-cols-1 md:grid-cols-4 gap-5">
<Card label="Tổng đơn hàng" value={stats.totalOrder} />
<Card label="Doanh thu (đã giao)" value={formatCurrency(stats.revenue)} />
<Card label="Tổng sản phẩm" value={stats.totalProduct} />
<Card label="Tổng khách hàng" value={stats.totalUser} />
</div>
);
};
export default Dashboard;
Ba điểm cần nêu là chưa tối ưu:
- Tải toàn bộ 3 collection về client — nên thay bằng aggregation ở server.
getUsers()trả về cả trườngpasswordđã băm (sự thật B.11) — càng không nên tải về.Promise.allsẽ hỏng cả 3 nếu 1 API lỗi; nên dùngPromise.allSettledđể phần nào chạy được thì vẫn hiện.
Bài 3 (khó). Thay chuỗi ternary lồng 5 tầng của trạng thái đơn hàng bằng một cấu hình tập trung, rồi dùng nó ở cả hai nơi (OrderList.js và CartDetailPage.js) — đồng thời chặn việc chuyển trạng thái sai (ví dụ từ 4 về 1) ở phía server.
Phía frontend — tạo file cấu hình dùng chung (file mới, bạn tự tạo):
// yotea-fe/src/constants/orderStatus.js ← file MỚI
export const ORDER_STATUS = {
NEW: 0,
CONFIRMED: 1,
SHIPPING: 2,
DELIVERED: 3,
CANCELLED: 4,
};
export const ORDER_STATUS_LABEL = {
0: "Đơn hàng mới",
1: "Đã xác nhận",
2: "Đang giao hàng",
3: "Đã giao hàng",
4: "Đã hủy",
};
// bước kế tiếp hợp lệ của mỗi trạng thái
export const NEXT_STATUS = {
0: [1, 4],
1: [2, 4],
2: [3, 4],
3: [],
4: [],
};
export const getStatusLabel = (status) =>
ORDER_STATUS_LABEL[status] ?? "Không xác định";
Trong OrderList.js thay cả khối ternary bằng {getStatusLabel(item.status)}.
Trong CartDetailPage.js, các nút được sinh tự động:
{NEXT_STATUS[order.status]?.map((next) => (
<button key={next} onClick={() => handleUpdateStt(next)} className="...">
{next === 4 ? "Hủy ĐH" : `Chuyển sang: ${getStatusLabel(next)}`}
</button>
))}
Phía server — chặn thật sự, vì client chỉ ẩn nút chứ không ngăn được ai gọi thẳng API:
// yotea-be/src/controllers/order.js — trong hàm update()
const NEXT_STATUS = { 0: [1, 4], 1: [2, 4], 2: [3, 4], 3: [], 4: [] };
export const update = async (req, res) => {
try {
const current = await Order.findById(req.params.id);
if (!current) return res.status(404).json({ message: "Không tìm thấy đơn hàng" });
const next = Number(req.body.status);
if (next !== current.status && !NEXT_STATUS[current.status].includes(next)) {
return res.status(400).json({
message: `Không thể chuyển đơn từ trạng thái ${current.status} sang ${next}`,
});
}
// chỉ nhận đúng trường status, KHÔNG nhét cả req.body vào
const order = await Order.findByIdAndUpdate(
req.params.id,
{ status: next },
{ new: true }
);
res.json(order);
} catch (error) {
res.status(400).json({ message: "Cập nhật đơn hàng thất bại", error });
}
};
Điểm quan trọng nhất của lời giải: quy tắc nghiệp vụ phải nằm ở server. Ẩn nút chỉ là trải nghiệm người dùng, không phải bảo mật.
</details>📌 Tóm tắt
- Khu
/admingồm 8 nhóm màn hình + Dashboard, tất cả nằm trong<PrivateRouter page="admin"><AdminLayout /></PrivateRouter>;AdminLayoutgiữ sidebar/header cố định và nhét route con vào<Outlet />. Dashboard.jschỉ có 7 dòng, không hiển thị số liệu nào. Mục menu Profile là link chết vì 2 trangpages/admin/profile/*chưa bao giờ được nối route.- Mọi màn quản trị đi theo đúng một khuôn mẫu: List (công thức phân trang
limit = 10→ bảng → Swal xoá →AdminPagination) → Add (yup cóimage→uploadFile→ POST →toast+reset()) → Edit (yup bỏimage→get()+reset(data)→ chỉ upload khitypeof data.image === "object" && data.image.length→ PUT →navigate). - Ảnh không đi qua backend Yotea.
uploadFile()(utils/index.js:3-12) đẩyFormDatathẳng lên Cloudinary bằng unsigned upload với cloud namelevantuanvà presetkkio3wiw— tài khoản của tác giả gốc, hardcode, có thể đã chết. Hãy tạo tài khoản riêng. Hàm còn trảdata.url(http) thay vìdata.secure_url(https). AdminPaginationvàPaginationcùng thuật toán, khác lớp áo; khác biệt thật sự chỉ làAdminPaginationtự chèn tiền tố/adminvào URL.- Đơn hàng có 5 trạng thái
0→4; admin đổi bằnghandleUpdateStt(n)→PUT /api/orders/:id/:userId— nhưng server không kiểm tra chuyển trạng thái hợp lệ, và các routeGET/DELETE /orderskhông có middleware bảo vệ. - Dự án trộn 3 phong cách lấy dữ liệu trong cùng khu admin (RTK Query / thunk / axios trần), và
ProductListPagegọiuseGetProductsQuery({})không phân trang để đếm tổng → mỗi lần vào trang là 2 request, một trong đó tải toàn bộ bảng.
Từ khoá tra cứu thêm: cloudinary unsigned upload preset, FormData multipart/form-data, react-hook-form reset, yup schema validation, sweetalert2 confirm dialog, react-toastify, react-router Outlet, X-Total-Count pagination header, RTK Query invalidatesTags
➡️ Bài tiếp theo: 33 — Rà soát bảo mật: dự án đang sai ở đâu — bạn đã thấy GET /api/orders cho ai cũng đọc được toàn bộ số điện thoại khách hàng. Bài sau ta gom hết những lỗ hổng như thế lại một chỗ, xếp hạng mức độ nguy hiểm, và bạn sẽ tự tay khai thác vài lỗ để hiểu vì sao chúng nghiêm trọng.
All rights reserved