diff --git a/db.json b/db.json
index 3cc6cc1..8529753 100644
--- a/db.json
+++ b/db.json
@@ -77,5 +77,104 @@
"iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759888364/oululvqiwuwpn8se3dww.png",
"isActive": true
}
+ ],
+ "products": [
+ {
+ "id": "aaa",
+ "categoryId": "aa",
+ "price": 50000,
+ "iconUrl": "https://cdn2.cellphones.com.vn/insecure/rs:fill:358:358/q:90/plain/https://cellphones.com.vn/media/catalog/product/i/p/iphone-15-plus-256gb_2.png",
+ "isActive": true,
+ "name": "IP Pro",
+ "des": "asasasa"
+ },
+ {
+ "id": "f52d",
+ "name": "Sản Phẩm Mới",
+ "price": 10000,
+ "iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759972232/vako1ivmnk8oqfjvxu5e.png",
+ "categoryId": "37f1",
+ "des": ""
+ },
+ {
+ "id": "d33f",
+ "name": "asdas",
+ "price": 12312312,
+ "iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759972302/om1v3jypr8kilajpyjrq.png",
+ "categoryId": "bb",
+ "des": ""
+ },
+ {
+ "id": "89fb",
+ "name": "asdas",
+ "price": 13212312,
+ "iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759972392/xkighdvy507fbpcuwvft.png",
+ "categoryId": "20d1",
+ "des": "",
+ "isActive": true
+ },
+ {
+ "id": "293d",
+ "name": "asdas",
+ "price": 13212312,
+ "iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759972446/cykgd7rr2biamil4wmbp.png",
+ "categoryId": "20d1",
+ "des": "
This is the initial content of the easdasdasditor.
",
+ "isActive": true
+ },
+ {
+ "id": "d14d",
+ "name": "asdas131231",
+ "price": 13212312,
+ "iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759972577/qhcajdypn0hkbpj8wngn.png",
+ "categoryId": "37f1",
+ "des": "This is the initial content of the easdasdasditor.adasdasdasd
\nasdasd
\nasdas
\nasdas
\nasda
\n
\n
\n
",
+ "isActive": true
+ },
+ {
+ "id": "0f28",
+ "name": "Yasdasdassd",
+ "price": 121212,
+ "iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759972822/vuwgc9qc4ibywllovgfi.png",
+ "categoryId": "37f1",
+ "des": "This is the initial content of the editor.
",
+ "isActive": true
+ },
+ {
+ "id": "1d6b",
+ "name": "Yasdasdassd",
+ "price": 121212,
+ "iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759972847/tyb4pyhwre1mhwcean5v.png",
+ "categoryId": "37f1",
+ "des": "This is the initial content of the editor.
",
+ "isActive": true
+ },
+ {
+ "id": "e718",
+ "name": "Yasdasdassd",
+ "price": 121212,
+ "iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759972882/f1ycf1xtlevvo4jwqrwh.png",
+ "categoryId": "37f1",
+ "des": "This is the initial content of the editor.
",
+ "isActive": true
+ },
+ {
+ "id": "b4c9",
+ "name": "assda",
+ "price": 1231231,
+ "iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759973036/zf8euabhj88wvgqpa1n4.png",
+ "categoryId": "bb",
+ "des": "This is the initial content of the editor.
\n
\n
",
+ "isActive": true
+ },
+ {
+ "id": "8d17",
+ "name": "s1231 56786786",
+ "price": 12321312,
+ "iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759973083/whwafvfupldclzjxn9bs.png",
+ "categoryId": "545a",
+ "des": "This is the initial content of the editor.
\n
",
+ "isActive": true
+ }
]
}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 63a7899..1d705c0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,11 @@
"name": "react_project",
"version": "0.0.0",
"dependencies": {
+ "@ant-design/icons": "^5.6.1",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@reduxjs/toolkit": "^2.9.0",
"@tailwindcss/vite": "^4.1.13",
+ "@tinymce/tinymce-react": "^6.3.0",
"antd": "^5.27.4",
"axios": "^1.12.2",
"cloudinary": "^2.7.0",
@@ -2463,6 +2465,25 @@
"node": ">=12.20"
}
},
+ "node_modules/@tinymce/tinymce-react": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/@tinymce/tinymce-react/-/tinymce-react-6.3.0.tgz",
+ "integrity": "sha512-E++xnn0XzDzpKr40jno2Kj7umfAE6XfINZULEBBeNjTMvbACWzA6CjiR6V8eTDc9yVmdVhIPqVzV4PqD5TZ/4g==",
+ "license": "MIT",
+ "dependencies": {
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0",
+ "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0",
+ "tinymce": "^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1"
+ },
+ "peerDependenciesMeta": {
+ "tinymce": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -4185,7 +4206,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -4576,6 +4596,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
"node_modules/lowdb": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz",
@@ -4786,6 +4818,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4926,6 +4967,23 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
diff --git a/package.json b/package.json
index e45936d..1171915 100644
--- a/package.json
+++ b/package.json
@@ -11,9 +11,11 @@
"preview": "vite preview"
},
"dependencies": {
+ "@ant-design/icons": "^5.6.1",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@reduxjs/toolkit": "^2.9.0",
"@tailwindcss/vite": "^4.1.13",
+ "@tinymce/tinymce-react": "^6.3.0",
"antd": "^5.27.4",
"axios": "^1.12.2",
"cloudinary": "^2.7.0",
diff --git a/src/RouterSetup.tsx b/src/RouterSetup.tsx
index 5b4e4c9..30796bb 100644
--- a/src/RouterSetup.tsx
+++ b/src/RouterSetup.tsx
@@ -6,6 +6,8 @@ import ProtectedAdmin from './pages/admin/auth/ProtectedAdmin'
import Auth from './pages/home/auth/Auth'
import UserManagement from './pages/admin/User/UserManagement'
import CategoryManagement from './pages/admin/Category/CategoryManagement'
+import ProductManagement from './pages/admin/Product/ProductManagement'
+import CreateProductForm from './pages/admin/Product/components/CreateProductForm'
export default function RouterSetup() {
return
@@ -18,6 +20,9 @@ export default function RouterSetup() {
}>
}>
}>
+ }>
+ }>
+
}
diff --git a/src/apis/core/product.api.ts b/src/apis/core/product.api.ts
new file mode 100644
index 0000000..254f2fa
--- /dev/null
+++ b/src/apis/core/product.api.ts
@@ -0,0 +1,23 @@
+import axios from "axios"
+
+export interface CreateProductDTO {
+ categorieId: string;
+ price: number;
+ iconUrl: string;
+ name: string;
+ des: string;
+}
+
+export const ProductApi = {
+ findAll: async () => {
+ let result = await axios.get(`${import.meta.env.VITE_SV_HOST}/products`)
+ return result.data
+ },
+ create: async (data: CreateProductDTO) => {
+ let result = await axios.post(`${import.meta.env.VITE_SV_HOST}/products`, {
+ ...data,
+ isActive: true
+ })
+ return result.data
+ }
+}
\ No newline at end of file
diff --git a/src/apis/index.ts b/src/apis/index.ts
index 557f8ef..8986f42 100644
--- a/src/apis/index.ts
+++ b/src/apis/index.ts
@@ -1,9 +1,11 @@
import { CategoryApi } from "./core/category.api";
import { CloudianryApi } from "./core/cloudinary.api";
+import { ProductApi } from "./core/product.api";
import { UserApi } from "./core/user.api";
export const Apis = {
user: UserApi,
category: CategoryApi,
- cloundInary: CloudianryApi
+ cloundInary: CloudianryApi,
+ product: ProductApi
}
\ No newline at end of file
diff --git a/src/pages/admin/Product/ProductManagement.tsx b/src/pages/admin/Product/ProductManagement.tsx
new file mode 100644
index 0000000..fec20a6
--- /dev/null
+++ b/src/pages/admin/Product/ProductManagement.tsx
@@ -0,0 +1,136 @@
+import React, { useEffect, useState } from 'react'
+import type { Product } from '../../../types/product.type'
+import { Apis } from '../../../apis'
+import { Button, Space, Table } from 'antd'
+import { Outlet, useNavigate } from 'react-router'
+import { useSelector } from 'react-redux'
+import type { StoreType } from '../../../stores'
+import type { Category } from '../../../types/category.type'
+
+export default function ProductManagement() {
+ const [categoryList, setCategoryList] = useState([])
+ const navigate = useNavigate()
+ const [productList, setProductList] = useState([])
+ const [selectProduct, setSelectProduct] = useState(null)
+
+
+ async function fetchProductList() {
+ try {
+ await Apis.category.getAll()
+ .then(res => {
+ setCategoryList(res)
+ })
+
+ let result = await Apis.product.findAll()
+ setProductList(result)
+ } catch (err) {
+
+ }
+ }
+
+
+
+
+ useEffect(() => {
+ fetchProductList()
+ }, [])
+ const columns = [
+
+ {
+ title: 'Số Thứ Tự',
+ key: 'index',
+ render: (_: any, __: any, index: number) => index + 1
+ },
+ {
+ title: 'Tên sản phẩm',
+ dataIndex: 'name',
+ key: 'name',
+ },
+ {
+ title: 'Giá sản phẩm',
+ dataIndex: 'price',
+ key: 'price',
+ },
+ {
+ title: 'Tên danh mục',
+ dataIndex: 'categoryId',
+ key: 'categoryId',
+ render: (_: any, record: Product) => (
+
+ {categoryList.find((item) => {
+ return item.id == record.categoryId
+ })?.title || "Unknow"}
+
+ )
+ },
+ {
+ title: 'Icon',
+ dataIndex: 'iconUrl',
+ key: 'iconUrl',
+ render: (_: any, record: Product) => (
+
+
+
+ )
+ },
+ {
+ title: 'Trạng Thái',
+ dataIndex: 'isActive',
+ key: 'isActive',
+ render: (_: any, record: Product) => (
+
+ {
+ record.isActive ?
+
+ :
+ }
+
+ )
+ },
+ {
+ title: 'Mô Tả',
+ dataIndex: 'isActive',
+ key: 'isActive',
+ render: (_: any, record: Product) => (
+
+
+
+ )
+ },
+ {
+ title: 'Công Cụ',
+ dataIndex: 'isActive',
+ key: 'isActive',
+ render: (_: any, record: Product) => (
+
+
+
+
+ )
+ }
+ ];
+
+ return (
+
+ {
+ selectProduct &&
+ }
+
ProductManagement
+
+
+
+
+
+ )
+}
diff --git a/src/pages/admin/Product/components/CreateProductForm.tsx b/src/pages/admin/Product/components/CreateProductForm.tsx
new file mode 100644
index 0000000..e93fa14
--- /dev/null
+++ b/src/pages/admin/Product/components/CreateProductForm.tsx
@@ -0,0 +1,149 @@
+import React, { useEffect, useRef, useState } from 'react'
+
+import { Editor } from '@tinymce/tinymce-react';
+import type { Category } from '../../../../types/category.type';
+import { Apis } from '../../../../apis';
+import { Button, Form, Input, InputNumber, Select, Upload } from 'antd';
+import { UploadOutlined } from '@ant-design/icons';
+import { useNavigate } from 'react-router';
+import { useDispatch } from 'react-redux';
+import { loadingAction } from '../../../../stores/slices/loading.slice';
+
+
+
+export default function CreateProductForm() {
+ const dispatch = useDispatch()
+ const navigate = useNavigate()
+ const editorRef = useRef(null);
+ const [categoryList, setCategoryList] = useState([])
+
+ const [apiLoading, setApiLoading] = useState(false)
+
+ useEffect(() => {
+ Apis.category.getAll()
+ .then(res => {
+ setCategoryList(res)
+ })
+ }, [])
+
+ const [form] = Form.useForm()
+
+ const handleFinish = async (values: any) => {
+ try {
+
+ const file = values.iconUrl.file;
+ if (!file) {
+ alert("phải chọn hình")
+ return
+ }
+ let iconUrl = await Apis.cloundInary.upload(file)
+ const data = {
+ ...values,
+ iconUrl: iconUrl,
+ des: editorRef.current?.getContent()
+ }
+ console.log('data', data)
+
+ let result = await Apis.product.create(data);
+ window.location.href = "/admin/product"
+
+ } catch (er) {
+
+ }
+ }
+
+ return (
+
+
Thêm sản phẩm
+ {
+ apiLoading ? <>Đang thêm ....> : <>
+
+
+
+
+
+
+
+
+
e}
+ rules={[{ required: true, message: 'Vui lòng chọn hình ảnh!' }]}
+ >
+ false} // chặn upload tự động, chỉ lấy file
+ maxCount={1}
+ listType="picture"
+ >
+ }>Chọn ảnh
+
+
+
+
+
+
+
+
+
+
+
+
Mô Tả Sản Phẩm
+
editorRef.current = editor}
+ initialValue="This is the initial content of the editor.
"
+ init={{
+ height: 500,
+ menubar: false,
+ plugins: [
+ 'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
+ 'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
+ 'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
+ ],
+ toolbar: 'undo redo | blocks | ' +
+ 'bold italic forecolor | alignleft aligncenter ' +
+ 'alignright alignjustify | bullist numlist outdent indent | ' +
+ 'removeformat | help',
+ content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }'
+ }}
+ />
+ >
+ }
+
+
+ )
+}
diff --git a/src/pages/admin/components/Slider.tsx b/src/pages/admin/components/Slider.tsx
index bc72431..d71fcd5 100644
--- a/src/pages/admin/components/Slider.tsx
+++ b/src/pages/admin/components/Slider.tsx
@@ -18,6 +18,11 @@ export default function Slider({ collapsed }: { collapsed: boolean }) {
key: 'category',
icon: ,
label: "Quản lý danh mục",
+ },
+ {
+ key: 'product',
+ icon: ,
+ label: "Quản lý sản phẩm",
}
]
diff --git a/src/stores/index.ts b/src/stores/index.ts
index cc891b2..5a4666d 100644
--- a/src/stores/index.ts
+++ b/src/stores/index.ts
@@ -1,9 +1,11 @@
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import { userAction, userReducer } from "./slices/user.slice";
+import { loadingReducer } from "./slices/loading.slice";
const RootReducer = combineReducers({
- user: userReducer
+ user: userReducer,
+ loading: loadingReducer
})
export type StoreType = ReturnType
diff --git a/src/stores/slices/loading.slice.ts b/src/stores/slices/loading.slice.ts
new file mode 100644
index 0000000..73254cf
--- /dev/null
+++ b/src/stores/slices/loading.slice.ts
@@ -0,0 +1,14 @@
+import { createSlice } from "@reduxjs/toolkit";
+
+const loadingSlice = createSlice({
+ name: "loading",
+ initialState: false,
+ reducers: {
+ change: (state) => {
+ state = !state
+ }
+ }
+})
+
+export const loadingReducer = loadingSlice.reducer
+export const loadingAction = loadingSlice.actions
\ No newline at end of file
diff --git a/src/types/product.type.ts b/src/types/product.type.ts
new file mode 100644
index 0000000..d8470a0
--- /dev/null
+++ b/src/types/product.type.ts
@@ -0,0 +1,9 @@
+export interface Product {
+ id: string;
+ categoryId: string;
+ price: number;
+ iconUrl: string;
+ isActive: boolean;
+ name: string;
+ des: string;
+}
\ No newline at end of file