# Bài 20 — CRUD admin đầy đủ
⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️
🎯 Mục tiêu
Source gốc chỉ có thêm sản phẩm. Bài này bổ sung:
PUT /products/:id— sửa sản phẩm (kèm đổi ảnh)DELETE /products/:id— xóa sản phẩm (kèm xóa file ảnh)- Trang
/admin/product/edit/[id] - Trang
/admin/orders— quản lý đơn hàng, đổi trạng thái - Dùng
mutatecủa SWR để cập nhật giao diện ngay sau khi thay đổi
📚 1. CRUD là gì?
| Chữ | Đầy đủ | HTTP | Trong dự án |
|---|---|---|---|
| C | Create | POST |
✅ Bài 07 |
| R | Read | GET |
✅ Bài 06 |
| U | Update | PUT / PATCH |
⬅️ Bài này |
| D | Delete | DELETE |
⬅️ Bài này |
PUT hay PATCH?
PUT |
PATCH |
|
|---|---|---|
| Ý nghĩa | Thay toàn bộ tài nguyên | Sửa một phần |
| Body | Đầy đủ mọi trường | Chỉ trường muốn đổi |
Bài này dùng PUT cho sản phẩm (form sửa gửi lại đầy đủ) và
PATCH cho trạng thái đơn hàng (chỉ đổi 1 trường).
💻 2. Backend: sửa sản phẩm
Thêm vào backend/routes/products.js:
const fs = require("fs");
const { verifyToken, requireAdmin } = require("../middleware/auth");
// ============================================
// PUT /products/:id — Cập nhật sản phẩm
// ============================================
router.put("/:id", verifyToken, requireAdmin, upload.single("image"),
async (req, res, next) => {
try {
const { id } = req.params;
if (!ObjectId.isValid(id)) {
return res.status(400).json({ message: "ID không hợp lệ" });
}
const db = await connectDb();
const products = db.collection("products");
const oldProduct = await products.findOne({ _id: new ObjectId(id) });
if (!oldProduct) {
return res.status(404).json({ message: "Không tìm thấy sản phẩm" });
}
const { name, price, description, categoryId } = req.body;
const updateData = {};
// Chỉ cập nhật trường nào được gửi lên
if (name !== undefined) {
if (!name.trim()) return res.status(400).json({ message: "Tên không được để trống" });
updateData.name = name.trim();
}
if (price !== undefined) {
const p = Number(price);
if (isNaN(p) || p <= 0) return res.status(400).json({ message: "Giá phải là số dương" });
updateData.price = p;
}
if (description !== undefined) updateData.description = description.trim();
if (categoryId !== undefined) {
if (!ObjectId.isValid(categoryId)) {
return res.status(400).json({ message: "Danh mục không hợp lệ" });
}
const cat = await db.collection("categories")
.findOne({ _id: new ObjectId(categoryId) });
if (!cat) return res.status(400).json({ message: "Danh mục không tồn tại" });
updateData.categoryId = new ObjectId(categoryId);
}
// Nếu có ảnh mới → cập nhật và xóa ảnh cũ
if (req.file) {
updateData.image = req.file.filename;
}
updateData.updatedAt = new Date();
const result = await products.updateOne(
{ _id: new ObjectId(id) },
{ $set: updateData }
);
// Xóa file ảnh cũ SAU KHI đã cập nhật DB thành công
if (req.file && oldProduct.image && oldProduct.image !== req.file.filename) {
const oldPath = path.join(__dirname, "../public/img", oldProduct.image);
fs.unlink(oldPath, (err) => {
if (err) console.warn("Không xóa được ảnh cũ:", oldProduct.image);
});
}
res.status(200).json({
message: "Cập nhật thành công",
modifiedCount: result.modifiedCount,
});
} catch (error) {
next(error);
}
}
);
🔍 Ba điểm quan trọng
1. if (field !== undefined) — chỉ cập nhật trường được gửi
Nếu dùng if (name) thì chuỗi rỗng "" bị coi là "không gửi" — không phân biệt được
"không muốn đổi" và "muốn đổi thành rỗng".
2. Xóa ảnh cũ SAU KHI cập nhật DB thành công
Nếu xóa trước mà updateOne thất bại, bạn mất ảnh mà dữ liệu vẫn trỏ tới nó.
3. fs.unlink bất đồng bộ, chỉ cảnh báo khi lỗi
File có thể đã bị xóa thủ công. Đây không phải lỗi nghiêm trọng đủ để làm hỏng cả request.
💻 3. Backend: xóa sản phẩm
// ============================================
// DELETE /products/:id — Xóa sản phẩm
// ============================================
router.delete("/:id", verifyToken, requireAdmin, async (req, res, next) => {
try {
const { id } = req.params;
if (!ObjectId.isValid(id)) {
return res.status(400).json({ message: "ID không hợp lệ" });
}
const db = await connectDb();
const products = db.collection("products");
const product = await products.findOne({ _id: new ObjectId(id) });
if (!product) {
return res.status(404).json({ message: "Không tìm thấy sản phẩm" });
}
await products.deleteOne({ _id: new ObjectId(id) });
// Xóa file ảnh kèm theo
if (product.image) {
const imgPath = path.join(__dirname, "../public/img", product.image);
fs.unlink(imgPath, (err) => {
if (err) console.warn("Không xóa được ảnh:", product.image);
});
}
res.status(200).json({ message: "Xóa sản phẩm thành công" });
} catch (error) {
next(error);
}
});
⚠️ Xóa cứng hay xóa mềm?
Đoạn trên là xóa cứng (hard delete) — dữ liệu biến mất vĩnh viễn.
Trong hệ thống thật, người ta thường dùng xóa mềm (soft delete):
await products.updateOne(
{ _id: new ObjectId(id) },
{ $set: { deleted: true, deletedAt: new Date() } }
);
Rồi lọc ở mọi truy vấn: .find({ deleted: { $ne: true } })
Ưu điểm của xóa mềm:
- Khôi phục được khi xóa nhầm
- Đơn hàng cũ vẫn tham chiếu được tới sản phẩm
- Giữ được dữ liệu để thống kê
Bài tập 4 sẽ hướng dẫn chuyển sang xóa mềm.
💻 4. Frontend: trang sửa sản phẩm
Tạo frontend/app/(admin)/admin/product/edit/[id]/page.js:
"use client"
import { useFormik } from "formik";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import useSWR from "swr";
import { fetcher, API, IMG, apiFetch } from "@/lib/fetcher";
export default function ProductEdit({ params }) {
const router = useRouter();
const { data: product, error, isLoading } = useSWR(
`${API}/products/id/${params.id}`, fetcher
);
const { data: categorylist } = useSWR(`${API}/categories`, fetcher);
const [preview, setPreview] = useState(null);
const [status, setStatus] = useState("");
const formik = useFormik({
enableReinitialize: true, // ← quan trọng: nạp lại khi data về
initialValues: {
name: product?.name ?? "",
description: product?.description ?? "",
categoryId: product?.categoryId ?? "",
price: product?.price ?? 0,
image: null, // null = giữ ảnh cũ
},
onSubmit: async (values) => {
setStatus("");
const formData = new FormData();
formData.append("name", values.name);
formData.append("description", values.description);
formData.append("categoryId", values.categoryId);
formData.append("price", values.price);
if (values.image) formData.append("image", values.image); // chỉ gửi khi có ảnh mới
try {
await apiFetch(`${API}/products/${params.id}`, {
method: "PUT",
body: formData,
});
router.push("/admin/product");
} catch (err) {
setStatus(err.message);
}
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div className="alert alert-danger">Không tìm thấy sản phẩm</div>;
return (
<>
<div className="d-flex justify-content-between">
<h3 className="mb-4">Sửa sản phẩm</h3>
<Link href="/admin/product" className="btn btn-outline-secondary rounded-0">
<i className="fas fa-arrow-left me-1"></i> Quay lại
</Link>
</div>
{status && <div className="alert alert-danger">{status}</div>}
<form className="row" onSubmit={formik.handleSubmit}>
<div className="col-md-8 mb-4">
<div className="card rounded-0 border-0 shadow-sm">
<div className="card-body">
<div className="mb-3">
<label htmlFor="name" className="form-label">Tên sản phẩm *</label>
<input type="text" className="form-control rounded-0"
id="name" name="name" required
value={formik.values.name} onChange={formik.handleChange} />
</div>
<div className="mb-3">
<label htmlFor="description" className="form-label">Mô tả</label>
<textarea className="form-control rounded-0" rows="6"
id="description" name="description"
value={formik.values.description} onChange={formik.handleChange} />
</div>
<div className="mb-3">
<label htmlFor="categoryId" className="form-label">Danh mục *</label>
<select className="form-select rounded-0"
id="categoryId" name="categoryId" required
value={formik.values.categoryId} onChange={formik.handleChange}>
<option value="">-- Chọn danh mục --</option>
{(categorylist ?? []).map((item) => (
<option key={item._id} value={item._id}>{item.name}</option>
))}
</select>
</div>
<div className="mb-3">
<label htmlFor="price" className="form-label">Giá (VNĐ) *</label>
<input type="number" className="form-control rounded-0"
id="price" name="price" min="0" step="1000" required
value={formik.values.price} onChange={formik.handleChange} />
</div>
</div>
</div>
</div>
<div className="col-md-4 mb-4">
<div className="card rounded-0 border-0 shadow-sm">
<div className="card-body">
<h6 className="pb-3 border-bottom">Hình ảnh</h6>
<div className="mb-3 text-center bg-secondary-subtle p-2">
<img
src={preview || `${IMG}${product.image}`}
alt={product.name}
className="w-75"
/>
<small className="d-block mt-2 text-muted">
{preview ? "Ảnh mới" : "Ảnh hiện tại"}
</small>
</div>
<input className="form-control rounded-0" type="file"
accept="image/*"
onChange={(event) => {
const file = event.currentTarget.files[0];
formik.setFieldValue("image", file);
setPreview(file ? URL.createObjectURL(file) : null);
}} />
<small className="text-muted">Để trống nếu không muốn đổi ảnh</small>
</div>
</div>
<button type="submit" className="btn btn-primary btn-lg rounded-0 mt-4 w-100"
disabled={formik.isSubmitting}>
{formik.isSubmitting ? "Đang lưu..." : "Lưu thay đổi"}
</button>
</div>
</form>
</>
);
}
🔍 enableReinitialize: true — chi tiết dễ bỏ sót
Formik chỉ đọc initialValues một lần khi khởi tạo. Nhưng dữ liệu SWR
về sau đó — lúc component render lần đầu, product vẫn là undefined.
Không có enableReinitialize, form sẽ luôn trống dù dữ liệu đã tải xong.
Đây là cùng loại bug với useRef ở bài 15: giá trị khởi tạo được chụp một lần
và không tự cập nhật.
💻 5. Frontend: nút Sửa/Xóa trong bảng
Cập nhật frontend/app/(admin)/admin/product/page.js:
"use client"
import Link from "next/link";
import useSWR from "swr";
import { fetcher, API, IMG, apiFetch } from "@/lib/fetcher";
import { formatVND } from "@/lib/price";
export default function AdminProduct() {
const { data: productlist, error, isLoading, mutate } = useSWR(
`${API}/products`, fetcher
);
const handleDelete = async (product) => {
if (!confirm(`Xóa sản phẩm "${product.name}"?\nHành động này không thể hoàn tác.`)) {
return;
}
try {
await apiFetch(`${API}/products/${product._id}`, { method: "DELETE" });
mutate(); // ← tải lại danh sách
} catch (err) {
alert(err.message);
}
};
if (isLoading) return <div>Loading...</div>;
if (error) return <div className="alert alert-danger">Lỗi: {error.message}</div>;
return (
<>
{/* ... phần header và thống kê như bài 17 ... */}
<table className="table text-center align-middle">
<tbody>
{productlist.map((product) => (
<tr key={product._id}>
{/* ... các cột như bài 17 ... */}
<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>
<Link href={`/admin/product/edit/${product._id}`}
className="btn btn-outline-warning btn-sm me-1" title="Sửa">
<i className="fas fa-pencil fa-fw"></i>
</Link>
<button onClick={() => handleDelete(product)}
className="btn btn-outline-danger btn-sm" title="Xóa">
<i className="fas fa-times fa-fw"></i>
</button>
</td>
</tr>
))}
</tbody>
</table>
</>
);
}
📚 mutate() — cập nhật giao diện sau khi thay đổi
SWR cache dữ liệu. Sau khi xóa, cache vẫn còn sản phẩm cũ.
mutate() bảo SWR "gọi lại API và cập nhật cache".
Ba cách dùng:
// 1. Gọi lại API (đơn giản, chậm hơn 1 request)
mutate();
// 2. Optimistic update — cập nhật UI ngay, không chờ server
mutate(productlist.filter((p) => p._id !== product._id), false);
// ↑ false = không revalidate
// 3. Cập nhật ngay + xác nhận lại với server (tốt nhất)
mutate(
async () => {
await apiFetch(url, { method: "DELETE" });
return productlist.filter((p) => p._id !== product._id);
},
{ optimisticData: productlist.filter((p) => p._id !== product._id), rollbackOnError: true }
);
Cách 3 cho trải nghiệm mượt nhất: giao diện phản hồi tức thì, và nếu server báo lỗi thì tự khôi phục lại trạng thái cũ.
💻 6. Trang quản lý đơn hàng
Tạo frontend/app/(admin)/admin/orders/page.js:
"use client"
import useSWR from "swr";
import { useState } from "react";
import { fetcher, API, apiFetch } from "@/lib/fetcher";
import { formatVND } from "@/lib/price";
const STATUS = {
pending: { label: "Chờ xác nhận", color: "warning" },
confirmed: { label: "Đã xác nhận", color: "info" },
shipping: { label: "Đang giao", color: "primary" },
done: { label: "Hoàn thành", color: "success" },
cancelled: { label: "Đã hủy", color: "danger" },
};
export default function AdminOrders() {
const { data: orders, error, isLoading, mutate } = useSWR(`${API}/orders`, fetcher);
const [expanded, setExpanded] = useState(null);
const changeStatus = async (orderId, status) => {
try {
await apiFetch(`${API}/orders/${orderId}/status`, {
method: "PATCH",
body: JSON.stringify({ status }),
});
mutate();
} catch (err) {
alert(err.message);
}
};
if (isLoading) return <div>Loading...</div>;
if (error) return <div className="alert alert-danger">Lỗi: {error.message}</div>;
const list = Array.isArray(orders) ? orders : [];
return (
<>
<h3 className="mb-4">Quản lý đơn hàng</h3>
{list.length === 0 && (
<div className="alert alert-info">Chưa có đơn hàng nào</div>
)}
<div className="card rounded-0 border-0 shadow-sm">
<div className="card-body">
<div className="table-responsive">
<table className="table align-middle">
<thead>
<tr>
<th>Mã đơn</th>
<th>Khách hàng</th>
<th>Điện thoại</th>
<th>Tổng tiền</th>
<th>Trạng thái</th>
<th></th>
</tr>
</thead>
<tbody>
{list.map((order) => (
<>
<tr key={order._id}>
<td><code>#{order._id.slice(-6).toUpperCase()}</code></td>
<td>{order.user?.fullname}</td>
<td>{order.user?.phone}</td>
<td className="fw-bold">{formatVND(order.total_money || 0)}</td>
<td>
<select
className={`form-select form-select-sm border-${
STATUS[order.status]?.color ?? "secondary"
}`}
value={order.status ?? "pending"}
onChange={(e) => changeStatus(order._id, e.target.value)}
>
{Object.entries(STATUS).map(([key, s]) => (
<option key={key} value={key}>{s.label}</option>
))}
</select>
</td>
<td>
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => setExpanded(expanded === order._id ? null : order._id)}
>
{expanded === order._id ? "Ẩn" : "Chi tiết"}
</button>
</td>
</tr>
{expanded === order._id && (
<tr key={`${order._id}-detail`}>
<td colSpan="6" className="bg-light">
<div className="p-3">
<p className="mb-2">
<strong>Địa chỉ:</strong> {order.user?.address}
</p>
<table className="table table-sm mb-0">
<thead>
<tr>
<th>Sản phẩm</th><th>Size</th>
<th>SL</th><th>Đơn giá</th><th>Thành tiền</th>
</tr>
</thead>
<tbody>
{(order.detail ?? []).map((item, i) => (
<tr key={i}>
<td>{item.name}</td>
<td>{item.size?.toUpperCase()}</td>
<td>{item.quantity}</td>
<td>{formatVND(item.price)}</td>
<td>
{formatVND(item.lineTotal ?? item.price * item.quantity)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
</div>
</div>
</div>
</>
);
}
⚠️ Đoạn dùng
<>...</>bên trong.map()không đặt đượckeytrên Fragment viết tắt. Cách đúng là dùng<React.Fragment key={order._id}>:import { Fragment } from "react"; // ... {list.map((order) => ( <Fragment key={order._id}> <tr>...</tr> {expanded === order._id && <tr>...</tr>} </Fragment> ))}Đây chính là lỗi mà
admin/product/page.jsgốc mắc phải (bài 17).
✅ 7. Kiểm thử
Test sửa sản phẩm
- Vào
/admin/product, bấm icon bút chì - ✅ Form hiện sẵn dữ liệu cũ (nếu trống → thiếu
enableReinitialize) - Đổi tên và giá, không chọn ảnh mới
- Bấm "Lưu thay đổi"
- ✅ Về danh sách, thấy tên/giá mới, ảnh vẫn là ảnh cũ
- Sửa lần nữa, lần này chọn ảnh mới
- ✅ Ảnh mới hiển thị
- ✅ Kiểm tra
backend/public/img/— file ảnh cũ đã bị xóa
Test xóa sản phẩm
- Bấm icon X → hiện hộp xác nhận
- Bấm Cancel → không có gì xảy ra
- Bấm lại, bấm OK
- ✅ Sản phẩm biến mất khỏi bảng ngay (nhờ
mutate()) - ✅ File ảnh đã bị xóa khỏi ổ đĩa
- ✅ Vào
/menubên khu khách hàng — sản phẩm cũng không còn
Test phân quyền
- Đăng xuất, dùng Postman gọi
DELETE /products/<id>không kèm token - ✅
401 Chưa đăng nhập - Đăng nhập bằng tài khoản
userthường, lấy token, gọi lại - ✅
403 Bạn không có quyền truy cập
Test đơn hàng
- Đặt vài đơn ở khu khách hàng
- Vào
/admin/orders - ✅ Thấy danh sách đơn, mới nhất trước
- Bấm "Chi tiết" → mở rộng hiện địa chỉ và danh sách sản phẩm
- Đổi trạng thái từ dropdown
- ✅ F5 → trạng thái được giữ nguyên (đã lưu vào DB)
📝 Bài tập
-
Chuyển sang xóa mềm: thêm
deleted: truethay vìdeleteOne, lọc{ deleted: { $ne: true } }ở mọi truy vấnGET, thêm trang "Thùng rác" với nút Khôi phục. -
Toast thay cho
alert(): dùngreact-hot-toast:npm install react-hot-toast -
Optimistic update cho xóa: dùng cách 3 ở mục 5, rồi tắt backend và thử xóa để xem hiệu ứng rollback.
-
Quản lý danh mục:
/admin/categoryvới đầy đủ CRUD. Chặn xóa danh mục còn sản phẩm. -
Xuất Excel danh sách đơn hàng:
npm install xlsx -
Tìm hiểu
enableReinitialize: tạm bỏ dòng đó ra khỏi form sửa, quan sát form trống trơn. Đọc lại bài 15 để thấy đây là cùng một loại lỗi vớiuseRef— giá trị khởi tạo bị chụp một lần.
⬅️ Bài trước | Mục lục | Bài tiếp theo: Tìm kiếm & phân trang ➡️
All rights reserved