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

\n

asdasd

\n

asdas

\n

asdas

\n

asda

\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" + > + + + + + + + + + + + + +

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