0

# 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 mutate củ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 được key trê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.js gốc mắc phải (bài 17).


✅ 7. Kiểm thử

Test sửa sản phẩm

  1. Vào /admin/product, bấm icon bút chì
  2. ✅ Form hiện sẵn dữ liệu cũ (nếu trống → thiếu enableReinitialize)
  3. Đổi tên và giá, không chọn ảnh mới
  4. Bấm "Lưu thay đổi"
  5. ✅ Về danh sách, thấy tên/giá mới, ảnh vẫn là ảnh cũ
  6. Sửa lần nữa, lần này chọn ảnh mới
  7. ✅ Ảnh mới hiển thị
  8. ✅ Kiểm tra backend/public/img/ — file ảnh cũ đã bị xóa

Test xóa sản phẩm

  1. Bấm icon X → hiện hộp xác nhận
  2. Bấm Cancel → không có gì xảy ra
  3. Bấm lại, bấm OK
  4. ✅ Sản phẩm biến mất khỏi bảng ngay (nhờ mutate())
  5. ✅ File ảnh đã bị xóa khỏi ổ đĩa
  6. ✅ Vào /menu bên khu khách hàng — sản phẩm cũng không còn

Test phân quyền

  1. Đăng xuất, dùng Postman gọi DELETE /products/<id> không kèm token
  2. ✅ 401 Chưa đăng nhập
  3. Đăng nhập bằng tài khoản user thường, lấy token, gọi lại
  4. ✅ 403 Bạn không có quyền truy cập

Test đơn hàng

  1. Đặt vài đơn ở khu khách hàng
  2. Vào /admin/orders
  3. ✅ Thấy danh sách đơn, mới nhất trước
  4. Bấm "Chi tiết" → mở rộng hiện địa chỉ và danh sách sản phẩm
  5. Đổi trạng thái từ dropdown
  6. ✅ F5 → trạng thái được giữ nguyên (đã lưu vào DB)

📝 Bài tập

  1. Chuyển sang xóa mềm: thêm deleted: true thay vì deleteOne, lọc { deleted: { $ne: true } } ở mọi truy vấn GET, thêm trang "Thùng rác" với nút Khôi phục.

  2. Toast thay cho alert(): dùng react-hot-toast:

    npm install react-hot-toast
    
  3. 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.

  4. Quản lý danh mục: /admin/category với đầy đủ CRUD. Chặn xóa danh mục còn sản phẩm.

  5. Xuất Excel danh sách đơn hàng:

    npm install xlsx
    
  6. 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ới useRef — 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

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í