6 Commits

26 changed files with 938 additions and 31 deletions

6
.env
View File

@@ -1 +1,7 @@
VITE_SV_HOST="http://localhost:3000" VITE_SV_HOST="http://localhost:3000"
VITE_JWT_TOKEN="phuocntbasdasasdasd"
VITE_CLOUDINARY_UPLOAD_PRESET="testpreset"
VITE_CLOUDINARY_CLOUD_NAME="dusw32tsq"
VITE_CLOUDINARY_API_SECRET="NeUuUfbjlZSSyq0Zf390LECRdcI"
VITE_CLOUDINARY_API_KEY="114544946391646"

View File

@@ -1,3 +1,4 @@
(Video)[https://rikkeieducation.sg.larksuite.com/minutes/obsgcl8da4w9x316w6jae174]
## Rikkei Store ## Rikkei Store
## Bán máy tính & điện thoại ## Bán máy tính & điện thoại
@@ -14,3 +15,9 @@
- Đăng ký / đăng nhập X - Đăng ký / đăng nhập X
- Layout người dùng X - Layout người dùng X
## Cloudinary
npm i cloudinary
## Update test

143
db.json

File diff suppressed because one or more lines are too long

101
package-lock.json generated
View File

@@ -8,11 +8,15 @@
"name": "react_project", "name": "react_project",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1",
"@ant-design/v5-patch-for-react-19": "^1.0.3", "@ant-design/v5-patch-for-react-19": "^1.0.3",
"@reduxjs/toolkit": "^2.9.0", "@reduxjs/toolkit": "^2.9.0",
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.13",
"@tinymce/tinymce-react": "^6.3.0",
"antd": "^5.27.4", "antd": "^5.27.4",
"axios": "^1.12.2", "axios": "^1.12.2",
"cloudinary": "^2.7.0",
"jose": "^6.1.0",
"json-server": "^1.0.0-beta.3", "json-server": "^1.0.0-beta.3",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
@@ -2461,6 +2465,25 @@
"node": ">=12.20" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3176,6 +3199,19 @@
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/cloudinary": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.7.0.tgz",
"integrity": "sha512-qrqDn31+qkMCzKu1GfRpzPNAO86jchcNwEHCUiqvPHNSFqu7FTNF9FuAkBUyvM1CFFgFPu64NT0DyeREwLwK0w==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21",
"q": "^1.5.1"
},
"engines": {
"node": ">=9"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4157,11 +4193,19 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jose": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
"integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
@@ -4539,6 +4583,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4546,6 +4596,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/lowdb": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz", "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz",
@@ -4756,6 +4818,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4896,6 +4967,23 @@
"node": ">= 0.8.0" "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": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -4912,6 +5000,17 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/q": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
"integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==",
"deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)",
"license": "MIT",
"engines": {
"node": ">=0.6.0",
"teleport": ">=0.2.0"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@@ -11,11 +11,15 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1",
"@ant-design/v5-patch-for-react-19": "^1.0.3", "@ant-design/v5-patch-for-react-19": "^1.0.3",
"@reduxjs/toolkit": "^2.9.0", "@reduxjs/toolkit": "^2.9.0",
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.13",
"@tinymce/tinymce-react": "^6.3.0",
"antd": "^5.27.4", "antd": "^5.27.4",
"axios": "^1.12.2", "axios": "^1.12.2",
"cloudinary": "^2.7.0",
"jose": "^6.1.0",
"json-server": "^1.0.0-beta.3", "json-server": "^1.0.0-beta.3",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",

View File

@@ -1,9 +1,18 @@
import { createContext } from 'react' import { createContext } from 'react'
import RouterSetup from './RouterSetup' import RouterSetup from './RouterSetup'
import { useSelector } from 'react-redux'
import type { StoreType } from './stores'
import Loading from './components/Loading'
export default function App() { export default function App() {
const userStore = useSelector((store: StoreType) => store.user)
return ( return (
<RouterSetup /> <>
{
userStore.loading ? <Loading />
: <RouterSetup />
}
</>
) )
} }

View File

@@ -5,6 +5,9 @@ import Admin from './pages/admin/Admin'
import ProtectedAdmin from './pages/admin/auth/ProtectedAdmin' import ProtectedAdmin from './pages/admin/auth/ProtectedAdmin'
import Auth from './pages/home/auth/Auth' import Auth from './pages/home/auth/Auth'
import UserManagement from './pages/admin/User/UserManagement' 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() { export default function RouterSetup() {
return <Routes> return <Routes>
@@ -16,7 +19,10 @@ export default function RouterSetup() {
</ProtectedAdmin> </ProtectedAdmin>
}> }>
<Route path='user' element={<UserManagement />}></Route> <Route path='user' element={<UserManagement />}></Route>
<Route path='category' element={<CategoryManagement />}></Route>
<Route path='product' element={<ProductManagement />}>
<Route path='add' element={<CreateProductForm />}></Route>
</Route>
</Route> </Route>
</Routes> </Routes>
} }

View File

@@ -0,0 +1,20 @@
import axios from "axios"
export const CategoryApi = {
getAll: async () => {
let resultRes = await axios.get(`${import.meta.env.VITE_SV_HOST}/categories`)
return resultRes.data
},
create: async (data: {
title: string;
iconUrl: string
}) => {
let result = await axios.post(`${import.meta.env.VITE_SV_HOST}/categories`, {
...data,
isActive: true
})
return result.data
}
}

View File

@@ -0,0 +1,12 @@
import axios from "axios"
export const CloudianryApi = {
upload: async (file: File) => {
let formData = new FormData()
formData.append("file", file)
formData.append("upload_preset", import.meta.env.VITE_CLOUDINARY_UPLOAD_PRESET)
formData.append("cloud_name", import.meta.env.VITE_CLOUDINARY_CLOUD_NAME)
let result = await axios.post(`https://api.cloudinary.com/v1_1/${import.meta.env.VITE_CLOUDINARY_CLOUD_NAME}/image/upload`, formData)
return result.data.secure_url
}
}

View File

@@ -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
}
}

View File

@@ -2,6 +2,7 @@ import axios from "axios"
import type { User } from "../../types/user.type" import type { User } from "../../types/user.type"
import { message } from "antd" import { message } from "antd"
import { ApiUtil } from "../../utils/api.util" import { ApiUtil } from "../../utils/api.util"
import * as jose from 'jose'
export interface UserSignInDTO { export interface UserSignInDTO {
emailOrUserName: string emailOrUserName: string
@@ -16,10 +17,7 @@ export interface UserFindAllDTO {
} }
export const UserApi = { export const UserApi = {
signIn: async (data: UserSignInDTO): Promise<{ signIn: async (data: UserSignInDTO) => {
message: string
data: any
}> => {
let userData = await axios.get(`${import.meta.env.VITE_SV_HOST}/users?email=${data.emailOrUserName}`) let userData = await axios.get(`${import.meta.env.VITE_SV_HOST}/users?email=${data.emailOrUserName}`)
if (userData.data.length == 0) { if (userData.data.length == 0) {
userData = await axios.get(`${import.meta.env.VITE_SV_HOST}/users?userName=${data.emailOrUserName}`) userData = await axios.get(`${import.meta.env.VITE_SV_HOST}/users?userName=${data.emailOrUserName}`)
@@ -36,10 +34,7 @@ export const UserApi = {
data: null data: null
}) })
} }
return { return createToken(userData.data[0].id)
message: "Đăng nhập thành công!",
data: userData.data[0] as User
}
} }
}, },
signUp: async (data: User) => { signUp: async (data: User) => {
@@ -106,5 +101,59 @@ export const UserApi = {
findAll: async (query?: UserFindAllDTO) => { findAll: async (query?: UserFindAllDTO) => {
let result = await axios.get(`${import.meta.env.VITE_SV_HOST}/users?` + ApiUtil.writeQuery(query)) let result = await axios.get(`${import.meta.env.VITE_SV_HOST}/users?` + ApiUtil.writeQuery(query))
return result.data return result.data
},
me: async (token: string) => {
let tokenData = await decodeToken(token)
if (!tokenData) {
throw ({
message: "Token không chính xác!"
})
}
let { userId } = tokenData;
let getUserByIdRes = await axios.get(`${import.meta.env.VITE_SV_HOST}/users/${userId}`)
if (!getUserByIdRes.data) {
throw ({
message: "Lỗi lấy dữ liệu"
})
}
let data = await new Promise((resolve) => {
setTimeout(() => {
resolve(getUserByIdRes.data)
}, 1000)
})
return data
}
}
async function createToken(userId: string) {
const secret = new TextEncoder().encode(import.meta.env.VITE_JWT_TOKEN);
const token = await new jose.SignJWT({ userId })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('2h')
.sign(secret);
return token
}
async function decodeToken(token: string) {
try {
const secret = new TextEncoder().encode(import.meta.env.VITE_JWT_TOKEN);
const { payload } = await jose.jwtVerify(token, secret, {
algorithms: ['HS256'],
});
return payload;
} catch (error) {
console.error('Token không hợp lệ hoặc đã hết hạn:', error);
return null;
} }
} }

View File

@@ -1,5 +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"; import { UserApi } from "./core/user.api";
export const Apis = { export const Apis = {
user: UserApi user: UserApi,
category: CategoryApi,
cloundInary: CloudianryApi,
product: ProductApi
} }

View File

@@ -0,0 +1,22 @@
import React from 'react'
export default function Loading() {
return (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-white/70 backdrop-blur-sm">
<div className="relative mb-4">
<div className="h-14 w-14 rounded-full bg-gray-200 animate-pulse" />
<svg
className="absolute -right-2 -bottom-2 h-7 w-7 animate-spin"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" className="text-gray-300 opacity-70" />
<path d="M22 12a10 10 0 00-10-10" stroke="currentColor" strokeWidth="3" className="text-indigo-500" strokeLinecap="round" />
</svg>
</div>
<div className="text-gray-600 font-medium">Đang tải dữ liệu xác thực...</div>
</div>
)
}

View File

@@ -0,0 +1,163 @@
import React, { useEffect, useState, type FormEvent } from 'react'
import { Apis } from '../../../apis'
import type { Category } from '../../../types/category.type'
import { Button, Form, Input, Modal, Space, Table, Upload } from 'antd'
import axios from 'axios'
export default function CategoryManagement() {
/* State Modal */
const [createNewModalState, setCreateNewModalState] = useState(false)
const [categories, setCategories] = useState<Category[]>([])
const [loadingState, setLoadingState] = useState(false)
async function getCategories() {
try {
let result = await Apis.category.getAll()
setCategories(result)
} catch (err) {
/* err */
alert("tạch!")
}
}
useEffect(() => {
getCategories()
}, [])
const columns = [
{
title: 'Số Thứ Tự',
key: 'index',
render: (_: any, __: any, index: number) => index + 1
},
{
title: 'Tiêu Đề',
dataIndex: 'title',
key: 'title',
},
{
title: 'Icon',
dataIndex: 'iconUrl',
key: 'iconUrl',
render: (_: any, record: Category) => (
<Space size="middle">
<img src={record.iconUrl} style={{
width: "50px",
height: "50px",
borderRadius: "50"
}} />
</Space>
)
},
{
title: 'Trạng Thái',
dataIndex: 'isActive',
key: 'isActive',
render: (_: any, record: Category) => (
<Space size="middle">
{
record.isActive ?
<Button color="danger" variant="solid">Ngừng Hoạt Đng</Button>
: <Button type='primary'>Kích Hoạt</Button>
}
</Space>
)
},
{
title: 'Công Cụ',
dataIndex: 'isActive',
key: 'isActive',
render: (_: any, record: Category) => (
<Space size="middle">
<Button color="danger" variant="solid">Xóa</Button>
<Button type='primary'>Sửa</Button>
</Space>
)
}
];
/* form */
const [form] = Form.useForm()
return (
<div style={{
position: "relative"
}}>
<h1>Quản Danh Mục</h1>
<Button onClick={() => {
setCreateNewModalState(true)
}} type='primary'>Thêm Mới</Button>
<Table
dataSource={categories}
columns={columns}
/>
{/* Modal Create new */}
<Modal
title="Thêm mới danh mục"
closable={{ 'aria-label': 'Custom Close Button' }}
open={createNewModalState}
onOk={() => {
if(loadingState) {
return
}
// setCreateNewModalState(false)
try {
form
.validateFields()
.then(async (values) => {
setLoadingState(true)
let result = await Apis.cloundInary.upload(values.file.file)
let newCategory = {
title: values.title,
iconUrl: result
}
let resultRes = await Apis.category.create(newCategory)
setCategories([
...categories,
resultRes
])
setLoadingState(false)
setCreateNewModalState(false)
})
} catch (er) {
setLoadingState(false)
setCreateNewModalState(true)
}
}}
onCancel={() => {
setCreateNewModalState(false)
}}
okButtonProps={{
htmlType: "submit"
}}
>
<Form form={form} layout="vertical">
<Form.Item
name="title"
label="Tên danh mục"
rules={[{ required: true, message: 'Nhập tên danh mục!' }]}
>
<Input placeholder="Nhập title" />
</Form.Item>
<Form.Item name="file" label="Ảnh">
<Upload beforeUpload={() => false}>
<Input type="file" />
</Upload>
</Form.Item>
</Form>
</Modal>
{/* Loading */}
{
loadingState && <div style={{
zIndex:"99999999"
}} className="absolute inset-0 flex items-center justify-center bg-white/60 backdrop-blur-sm z-50">
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
}
</div>
)
}

View File

@@ -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<Category[]>([])
const navigate = useNavigate()
const [productList, setProductList] = useState<Product[]>([])
const [selectProduct, setSelectProduct] = useState<Product>(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) => (
<Space size="middle">
<span>{categoryList.find((item) => {
return item.id == record.categoryId
})?.title || "Unknow"}</span>
</Space>
)
},
{
title: 'Icon',
dataIndex: 'iconUrl',
key: 'iconUrl',
render: (_: any, record: Product) => (
<Space size="middle">
<img src={record.iconUrl} style={{
width: "50px",
height: "50px",
borderRadius: "50"
}} />
</Space>
)
},
{
title: 'Trạng Thái',
dataIndex: 'isActive',
key: 'isActive',
render: (_: any, record: Product) => (
<Space size="middle">
{
record.isActive ?
<Button color="danger" variant="solid">Ngừng Hoạt Đng</Button>
: <Button type='primary'>Kích Hoạt</Button>
}
</Space>
)
},
{
title: 'Mô Tả',
dataIndex: 'isActive',
key: 'isActive',
render: (_: any, record: Product) => (
<Space size="middle">
<Button onClick={() => {
setSelectProduct(record)
}}>Xem Tả</Button>
</Space>
)
},
{
title: 'Công Cụ',
dataIndex: 'isActive',
key: 'isActive',
render: (_: any, record: Product) => (
<Space size="middle">
<Button color="danger" variant="solid">Xóa</Button>
<Button type='primary'>Sửa</Button>
</Space>
)
}
];
return (
<div>
{
selectProduct && <div className='des_ox' dangerouslySetInnerHTML={{ __html: selectProduct.des }}></div>
}
<h1>ProductManagement</h1>
<Button onClick={() => {
navigate("add")
}} type='primary'>Add New Product</Button>
<Outlet></Outlet>
<Table
dataSource={productList}
columns={columns}
/>
</div>
)
}

View File

@@ -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<Category[]>([])
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 (
<div style={{
border: "1px solid black"
}}>
<h1>Thêm sản phẩm</h1>
{
apiLoading ? <>Đang thêm ....</> : <>
<Form
form={form}
layout="vertical"
onFinish={handleFinish}
style={{ maxWidth: 500, margin: 'auto' }}
>
<Form.Item
label="Tên sản phẩm"
name="name"
rules={[{ required: true, message: 'Vui lòng nhập tên sản phẩm!' }]}
>
<Input placeholder="Nhập tên sản phẩm" />
</Form.Item>
<Form.Item
label="Giá sản phẩm"
name="price"
rules={[{ required: true, message: 'Vui lòng nhập giá sản phẩm!' }]}
>
<InputNumber
min={0}
style={{ width: '100%' }}
placeholder="Nhập giá sản phẩm"
/>
</Form.Item>
<Form.Item
label="Hình ảnh sản phẩm"
name="iconUrl"
valuePropName="file"
getValueFromEvent={(e) => e}
rules={[{ required: true, message: 'Vui lòng chọn hình ảnh!' }]}
>
<Upload
beforeUpload={() => false} // chặn upload tự động, chỉ lấy file
maxCount={1}
listType="picture"
>
<Button icon={<UploadOutlined />}>Chọn nh</Button>
</Upload>
</Form.Item>
<Form.Item
label="Danh mục"
name="categoryId"
rules={[{ required: true, message: 'Vui lòng chọn danh mục!' }]}
>
<Select placeholder="Chọn danh mục">
{categoryList.map((item) => (
<Select.Option key={item.id} value={item.id}>
{item.title}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
Thêm sản phẩm
</Button>
</Form.Item>
</Form>
<p> Tả Sản Phẩm</p>
<Editor
apiKey='gl284vy5pkwj6d6jdt9r7n6z7398b9gmowka4onfdqr1wq6h'
onInit={(_evt, editor) => editorRef.current = editor}
initialValue="<p>This is the initial content of the editor.</p>"
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 }'
}}
/>
</>
}
</div>
)
}

View File

@@ -14,7 +14,7 @@ export default function ProtectedAdmin(
return store.user return store.user
}) })
/* Đã đăng nhập và là master hoặc admin */
if (userStore.data?.role == UserRole.MASTER || userStore.data?.role == UserRole.ADMIN) { if (userStore.data?.role == UserRole.MASTER || userStore.data?.role == UserRole.ADMIN) {
return ( return (
<> <>
@@ -23,7 +23,8 @@ export default function ProtectedAdmin(
) )
} }
if (!userStore.data?.role) { /* chưa đăng nhập */
if (!userStore.data && !userStore.loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-4"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-4">
<div className="w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden"> <div className="w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden">
@@ -90,11 +91,16 @@ export default function ProtectedAdmin(
) )
} }
/* đã đăng nhập nhưng không phải master hoặc admin */
if (userStore.data?.role) { if (userStore.data?.role) {
window.location.href = "/" window.location.href = "/"
return <></> return <></>
} }
/* chưa vào case, default */
async function signInHandle(e: FormEvent) { async function signInHandle(e: FormEvent) {
e.preventDefault() e.preventDefault()
let data: UserSignInDTO = { let data: UserSignInDTO = {
@@ -103,10 +109,9 @@ export default function ProtectedAdmin(
} }
try { try {
let result = await Apis.user.signIn(data) let result = await Apis.user.signIn(data)
localStorage.setItem("userLogin", JSON.stringify(result.data)) localStorage.setItem("token", result)
Modal.confirm({ Modal.confirm({
title: "Đăng nhập thành công", title: "Đăng nhập thành công",
content: result.message,
onOk: () => { onOk: () => {
window.location.reload() window.location.reload()
}, },

View File

@@ -28,7 +28,7 @@ export default function HeaderCom({ collapsed, setCollapsed }: { collapsed: bool
</select> </select>
<Button onClick={() => { <Button onClick={() => {
localStorage.removeItem("userLogin") localStorage.removeItem("token")
window.location.reload() window.location.reload()
}}>logout</Button> }}>logout</Button>
</Header> </Header>

View File

@@ -13,6 +13,16 @@ export default function Slider({ collapsed }: { collapsed: boolean }) {
key: 'user', key: 'user',
icon: <UserOutlined />, icon: <UserOutlined />,
label: "Quản lý người dùng", label: "Quản lý người dùng",
},
{
key: 'category',
icon: <UserOutlined />,
label: "Quản lý danh mục",
},
{
key: 'product',
icon: <UserOutlined />,
label: "Quản lý sản phẩm",
} }
] ]

View File

@@ -49,9 +49,9 @@ export default function Auth() {
console.log("loginData", loginData) console.log("loginData", loginData)
try { try {
let data = await Apis.user.signIn(loginData) let data = await Apis.user.signIn(loginData)
localStorage.setItem("userLogin", data.data.id) localStorage.setItem("token", data)
Modal.confirm({ Modal.confirm({
title: `Chào mừng ${data.data.userName} đã quay trở lại`, title: `Chào mừng bạn đã quay trở lại`,
content: ``, content: ``,
onOk: () => { onOk: () => {
window.location.href = "/" window.location.href = "/"

View File

@@ -70,7 +70,7 @@ export default function Header() {
window.location.href = "/admin" window.location.href = "/admin"
}} className="fa-solid fa-lock"></i>} }} className="fa-solid fa-lock"></i>}
<i onClick={() => { <i onClick={() => {
window.localStorage.removeItem("userLogin") window.localStorage.removeItem("token")
window.location.href = "/auth" window.location.href = "/auth"
}} className="cursor-pointer fa-solid fa-right-from-bracket"></i> }} className="cursor-pointer fa-solid fa-right-from-bracket"></i>
</div> </div>

View File

@@ -1,9 +1,11 @@
import { combineReducers, configureStore } from "@reduxjs/toolkit"; import { combineReducers, configureStore } from "@reduxjs/toolkit";
import { userAction, userReducer } from "./slices/user.slice"; import { userAction, userReducer } from "./slices/user.slice";
import { loadingReducer } from "./slices/loading.slice";
const RootReducer = combineReducers({ const RootReducer = combineReducers({
user: userReducer user: userReducer,
loading: loadingReducer
}) })
export type StoreType = ReturnType<typeof RootReducer> export type StoreType = ReturnType<typeof RootReducer>

View File

@@ -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

View File

@@ -21,9 +21,16 @@ const userSlice = createSlice({
}, },
extraReducers: (bd) => { extraReducers: (bd) => {
bd.addCase(fetchUserData.pending, (state, action) => {
state.loading = true
})
bd.addCase(fetchUserData.fulfilled, (state, action) => { bd.addCase(fetchUserData.fulfilled, (state, action) => {
state.loading = false
state.data = action.payload state.data = action.payload
}) })
bd.addCase(fetchUserData.rejected, (state, action) => {
state.loading = false
})
} }
}) })
@@ -31,8 +38,8 @@ const userSlice = createSlice({
const fetchUserData = createAsyncThunk( const fetchUserData = createAsyncThunk(
"user/fetchUserData", "user/fetchUserData",
async () => { async () => {
let result = await Apis.user.findById(localStorage.getItem("userLogin")) let result = await Apis.user.me(localStorage.getItem("token")) as any
return result.data return result
} }
) )

View File

@@ -0,0 +1,6 @@
export interface Category {
id: string;
title: string;
iconUrl: string;
isActive: boolean;
}

View File

@@ -0,0 +1,9 @@
export interface Product {
id: string;
categoryId: string;
price: number;
iconUrl: string;
isActive: boolean;
name: string;
des: string;
}