Completed Change to ReactJS #5
@ -1,10 +1,10 @@
|
||||
|
||||
class MessageCode():
|
||||
CREATE_USER_SUCCESS: str = 'CREATE_USER_SUCCESS'
|
||||
CREATED_USER: str = 'CREATED_USER'
|
||||
WRONG_INPUT: str = 'LOGIN_WRONG'
|
||||
ACCOUNT_LOCK: str = 'USER_LOCK'
|
||||
CREATE_USER_SUCCESS: str = 'message_create_user_success'
|
||||
CREATED_USER: str = 'message_created_user'
|
||||
WRONG_INPUT: str = 'message_login_wrong'
|
||||
ACCOUNT_LOCK: str = 'message_user_lock'
|
||||
REFRESH_TOKEN_EXPIRED: str = 'REFRESH_TOKEN_EXPIRED'
|
||||
CREATE_HOUSE_FAIL: str = 'CREATE_HOUSE_FAIL'
|
||||
CREATE_HOUSE_SUCCESS: str = 'CREATE_HOUSE_SUCCESS'
|
||||
CREATE_HOUSE_FAIL: str = 'message_create_house_fail'
|
||||
CREATE_HOUSE_SUCCESS: str = 'message_create_house_success'
|
||||
HOUSE_NOT_FOUND: str = 'HOUSE_NOT_FOUND'
|
||||
|
@ -1,3 +1,4 @@
|
||||
from datetime import datetime
|
||||
from backend.db.models.houses import Houses, Areas
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@ -75,4 +76,15 @@ class RepositoryHouses:
|
||||
return db_house
|
||||
|
||||
def delete(self, db: Session, house_id: str):
|
||||
pass
|
||||
db_house = self.get_by_id(house_id)
|
||||
if not db_house:
|
||||
return None
|
||||
try:
|
||||
self.houses.query.where(Houses.id == house_id).update({"deleted_at": datetime.utcnow()})
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
db.refresh(db_house)
|
||||
return db_house
|
||||
|
@ -43,3 +43,8 @@ def get_house_by_id(house_id: str, current_user: current_user_token) -> ReturnVa
|
||||
def update_house(house: HouseUpdate, current_user: current_user_token, db: db_dependency) -> ReturnValue[Any]:
|
||||
db_house = house_service.update(db=db, house=house)
|
||||
return ReturnValue(status=200, data=db_house)
|
||||
|
||||
@public_router.delete("/delete/{house_id}", response_model=ReturnValue[Any])
|
||||
def delete_house(house_id: str, current_user: current_user_token, db: db_dependency) -> ReturnValue[Any]:
|
||||
db_house = house_service.delete(db=db, house_id=house_id)
|
||||
return ReturnValue(status=200, data="Deleted")
|
||||
|
@ -21,3 +21,6 @@ class HouseService(BaseService):
|
||||
|
||||
def update(self, db: Session, house: HouseUpdate):
|
||||
return self.repos.update(db=db, house=house)
|
||||
|
||||
def delete(self, db: Session, house_id: str):
|
||||
return self.repos.delete(db=db, house_id=house_id)
|
||||
|
@ -1,11 +1,13 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
},
|
||||
plugins: ['solid'],
|
||||
extends: ['eslint:recommended', 'plugin:solid/recommended'],
|
||||
env: { browser: true, es2021: true },
|
||||
plugins: ['react-refresh'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
env: {
|
||||
@ -18,15 +20,22 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
settings: { react: { version: '18.2' } },
|
||||
rules: {
|
||||
indent: ['warn', 2],
|
||||
'react/jsx-no-target-blank': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
// indent: ['warn', 2],
|
||||
quotes: ['error', 'single'],
|
||||
semi: ['error', 'never'],
|
||||
'max-lines-per-function': [1, 1000],
|
||||
'max-lines-per-function': [1, 300],
|
||||
'no-unused-vars': ['warn'],
|
||||
'no-prototype-builtins': 'off',
|
||||
'react/display-name': 'off',
|
||||
},
|
||||
}
|
||||
|
8
frontend/README.md
Normal file
8
frontend/README.md
Normal file
@ -0,0 +1,8 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
@ -15,7 +15,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="modal-portal"></div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,47 +1,50 @@
|
||||
{
|
||||
"name": "fuware",
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18.20.2"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"eslint": "eslint \"src/**/*.{js,jsx}\" --fix",
|
||||
"prettier": "prettier \"src/**/*.{js,jsx}\" --write"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@felte/reporter-solid": "^1.2.10",
|
||||
"@felte/solid": "^1.2.13",
|
||||
"@felte/validator-yup": "^1.1.3",
|
||||
"@solidjs/meta": "^0.29.4",
|
||||
"@solidjs/router": "^0.13.3",
|
||||
"@stitches/core": "^1.2.8",
|
||||
"@tabler/icons-solidjs": "^3.3.0",
|
||||
"axios": "^1.6.8",
|
||||
"@mantine/core": "^7.11.2",
|
||||
"@mantine/dates": "^7.11.2",
|
||||
"@mantine/form": "^7.11.2",
|
||||
"@mantine/hooks": "^7.11.2",
|
||||
"@mantine/modals": "^7.11.2",
|
||||
"@mantine/notifications": "^7.11.2",
|
||||
"@mantine/nprogress": "^7.11.2",
|
||||
"@tabler/icons-react": "^3.11.0",
|
||||
"axios": "^1.7.2",
|
||||
"crypto-js": "^4.2.0",
|
||||
"solid-js": "^1.8.15",
|
||||
"solid-toast": "^0.5.0",
|
||||
"dayjs": "^1.11.12",
|
||||
"i18next": "^23.12.2",
|
||||
"i18next-http-backend": "^2.5.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-router-dom": "^6.25.1",
|
||||
"swr": "^2.2.5",
|
||||
"uuid": "^10.0.0",
|
||||
"yup": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.19",
|
||||
"daisyui": "^4.11.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-solid": "^0.14.0",
|
||||
"lint-staged": "15.2.2",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "3.2.5",
|
||||
"sass": "^1.77.4",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"vite": "^5.2.0",
|
||||
"vite-plugin-mkcert": "^1.17.5",
|
||||
"vite-plugin-solid": "^2.10.2"
|
||||
},
|
||||
"proxy": "http://localhost:9000"
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.34.3",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"lint-staged": "^15.2.7",
|
||||
"postcss": "^8.4.39",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.3.3",
|
||||
"sass": "^1.77.8",
|
||||
"vite": "^5.3.4"
|
||||
}
|
||||
}
|
||||
|
3447
frontend/pnpm-lock.yaml
generated
3447
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
14
frontend/postcss.config.mjs
Normal file
14
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,14 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {},
|
||||
'postcss-simple-vars': {
|
||||
variables: {
|
||||
'mantine-breakpoint-xs': '36em',
|
||||
'mantine-breakpoint-sm': '48em',
|
||||
'mantine-breakpoint-md': '62em',
|
||||
'mantine-breakpoint-lg': '75em',
|
||||
'mantine-breakpoint-xl': '88em',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
64
frontend/public/i18n/en.json
Normal file
64
frontend/public/i18n/en.json
Normal file
@ -0,0 +1,64 @@
|
||||
{
|
||||
"ui_username": "Username",
|
||||
"ui_password": "Password",
|
||||
"ui_login": "Login",
|
||||
"ui_logout": "Logout",
|
||||
"ui_dashboard": "Dashboard",
|
||||
"ui_profile": "Profile",
|
||||
"ui_change_info": "Account information",
|
||||
"ui_save": "Save",
|
||||
"ui_clear": "Clear",
|
||||
"ui_house": "Location",
|
||||
"ui_action": "Action",
|
||||
"ui_create_new": "Create new",
|
||||
"ui_warehouse": "Warehouse",
|
||||
"ui_display_name": "Display name",
|
||||
"ui_new_password": "New password",
|
||||
"ui_confirm_new_password": "Confirm new password",
|
||||
"ui_new_house": "Create new location",
|
||||
"ui_edit_house": "Edit location",
|
||||
"ui_houseName": "Location name",
|
||||
"ui_house_icon": "Icon",
|
||||
"ui_house_address": "Address",
|
||||
"ui_areas": "Areas",
|
||||
"ui_area_name": "Area name",
|
||||
"ui_area_desc": "Description",
|
||||
"ui_add_area": "Add area",
|
||||
"ui_edit_area": "Sửa khu vực",
|
||||
"ui_create": "Create",
|
||||
"ui_update": "Update",
|
||||
"ui_delete": "Delete",
|
||||
"ui_confirm": "Confirm",
|
||||
"ui_cancel": "Cancel",
|
||||
"ui_find_icon_here": "Find icons here",
|
||||
"ui_empty": "Empty",
|
||||
"ui_error": "Error!",
|
||||
"ui_success": "Success!",
|
||||
"ui_info": "Information",
|
||||
"ui_loading": "Loading...",
|
||||
"ui_showing": "Showing",
|
||||
"table_col_no": "No.",
|
||||
"table_col_name": "Name",
|
||||
"table_col_icon": "Icon",
|
||||
"table_col_address": "Address",
|
||||
"table_col_action": "Action",
|
||||
"message_login_success": "Login successfully!",
|
||||
"message_welcom_back": "Welcome back!",
|
||||
"message_login_fail": "Login failed!",
|
||||
"message_logout_fail": "Logout failed!",
|
||||
"message_create_user_success": "Create account successfully!",
|
||||
"message_created_user": "Username is already in use!",
|
||||
"message_login_wrong": "Wrong username or password.",
|
||||
"message_user_lock": "Your account is locked.",
|
||||
"message_is_required": "{{field}} is required.",
|
||||
"message_password_mustmatch": "Password must match.",
|
||||
"message_update_success": "Update successfully!",
|
||||
"message_update_profile_success": "Update profile successfully!",
|
||||
"message_update_fail": "Update failed!",
|
||||
"message_api_call_fail": "API call failed!",
|
||||
"message_create_house_fail": "Create new location failed!",
|
||||
"message_create_house_success": "Create new location successfully!",
|
||||
"message_confirm_delete": "Are you sure you want to delete this item?",
|
||||
"message_confirm_delete_note": "Attention ‼: Once deleted, it cannot be recovered.",
|
||||
"message_min_three_char": "At least 3 characters are required."
|
||||
}
|
65
frontend/public/i18n/vi.json
Normal file
65
frontend/public/i18n/vi.json
Normal file
@ -0,0 +1,65 @@
|
||||
{
|
||||
"ui_username": "Tên người dùng",
|
||||
"ui_password": "Mật khẩu",
|
||||
"ui_login": "Đăng Nhập",
|
||||
"ui_logout": "Đăng xuất",
|
||||
"ui_dashboard": "Bảng điều khiển",
|
||||
"ui_profile": "Hồ sơ",
|
||||
"ui_change_info": "Thông tin tài khoản",
|
||||
"ui_save": "Lưu",
|
||||
"ui_clear": "Xóa",
|
||||
"ui_house": "Địa điểm",
|
||||
"ui_action": "Thao Tác",
|
||||
"ui_create_new": "Tạo mới",
|
||||
"ui_warehouse": "Khu vực",
|
||||
"ui_display_name": "Tên hiển thị",
|
||||
"ui_new_password": "Mật khẩu mới",
|
||||
"ui_confirm_new_password": "Nhập lại mật khẩu",
|
||||
"ui_new_house": "Tạo địa điểm mới",
|
||||
"ui_edit_house": "Sửa địa điểm",
|
||||
"ui_house_name": "Tên địa điểm",
|
||||
"ui_house_icon": "Ký tự",
|
||||
"ui_house_address": "Địa chỉ",
|
||||
"ui_areas": "Khu vực",
|
||||
"ui_area_name": "Tên khu vực",
|
||||
"ui_area_desc": "Mô tả",
|
||||
"ui_add_area": "Thêm khu vực",
|
||||
"ui_edit_area": "Sửa khu vực",
|
||||
"ui_create": "Tạo",
|
||||
"ui_update": "Cập nhật",
|
||||
"ui_delete": "Xóa",
|
||||
"ui_confirm": "Xác nhận",
|
||||
"ui_cancel": "Huỷ",
|
||||
"ui_find_icon_here": "Tìm ở đây",
|
||||
"ui_empty": "Trống",
|
||||
"ui_error": "lỗi!",
|
||||
"ui_success": "Thành công!",
|
||||
"ui_info": "Thông tin",
|
||||
"ui_loading": "Đang tải...",
|
||||
"ui_showing": "Hiển thị",
|
||||
"table_col_no": "STT",
|
||||
"table_col_name": "Tên",
|
||||
"table_col_icon": "Ký tự",
|
||||
"table_col_address": "Địa chỉ",
|
||||
"table_col_action": "Thao Tác",
|
||||
"message_login_success": "Đăng nhập thành công!",
|
||||
"message_welcom_back": "Chào mừng trở lại!",
|
||||
"message_login_fail": "Đăng nhập thất bại!",
|
||||
"message_logout_fail": "Đăng nhập thất bại!",
|
||||
"message_create_user_success": "Tạo tài khoản thành công!",
|
||||
"message_created_user": "Tên tài khoản đã có người sử dụng!",
|
||||
"message_login_wrong": "Bạn nhập sai tên người dùng hoặc mật khẩu.",
|
||||
"message_user_lock": "Tài khoản bạn hiện đang bị khóa.",
|
||||
"message_is_required": "{{field}} là bắt buộc.",
|
||||
"message_password_mustmatch": "Cần nhập trùng với mật khẩu.",
|
||||
"message_update_success": "Cập nhật thành công!",
|
||||
"message_update_profile_success": "Cập nhật hồ sơ thành công!",
|
||||
"message_update_fail": "Cập nhật thất bại!",
|
||||
"message_success_delete": "Xóa thành công!",
|
||||
"message_api_call_fail": "Call API có vẫn đề!",
|
||||
"message_action_house_fail": "{{action}} địa điểm mới thất bại!",
|
||||
"message_action_house_success": "{{action}} địa điểm mới thành công!",
|
||||
"message_confirm_delete": "Bạn có chắc là muốn xóa mục này?",
|
||||
"message_confirm_delete_note": "Chú Ý ‼: Một khi đã <strong>XÓA</strong> thì không thể nào khôi phục lại được.",
|
||||
"message_min_three_char": "Ít nhất phải có 3 ký tự"
|
||||
}
|
@ -1,22 +1,75 @@
|
||||
import { MetaProvider } from '@solidjs/meta'
|
||||
import { Toaster } from 'solid-toast'
|
||||
import './App.scss'
|
||||
import { SiteContextProvider } from './context/SiteContext'
|
||||
import { AuthProvider } from '@context/auth-context'
|
||||
import {
|
||||
createTheme,
|
||||
Input,
|
||||
InputWrapper,
|
||||
MantineProvider,
|
||||
} from '@mantine/core'
|
||||
import { ModalsProvider } from '@mantine/modals'
|
||||
import { Notifications } from '@mantine/notifications'
|
||||
import router from '@routes/routes'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
|
||||
function App(props) {
|
||||
const inputStyle = (theme) => ({
|
||||
root: {
|
||||
display: 'grid',
|
||||
gridTemplate: `"left mid right"
|
||||
"input input input"
|
||||
"desc desc desc" auto / auto 1fr 100px`,
|
||||
},
|
||||
description: {
|
||||
gridArea: 'desc',
|
||||
},
|
||||
label: {
|
||||
gridArea: 'left',
|
||||
marginRight: theme.spacing.sm,
|
||||
},
|
||||
error: {
|
||||
gridArea: 'mid',
|
||||
},
|
||||
})
|
||||
|
||||
const gTheme = createTheme({
|
||||
colors: {
|
||||
greed: [
|
||||
'#ddffff',
|
||||
'#c8fdff',
|
||||
'#98f8ff',
|
||||
'#62f3fe',
|
||||
'#38effc',
|
||||
'#1bedfc',
|
||||
'#00ecfd',
|
||||
'#00d2e1',
|
||||
'#00bbc9',
|
||||
'#00a2b0',
|
||||
],
|
||||
},
|
||||
primaryColor: 'greed',
|
||||
components: {
|
||||
InputWrapper: InputWrapper.extend({
|
||||
styles: inputStyle,
|
||||
}),
|
||||
Input: Input.extend({
|
||||
styles: {
|
||||
wrapper: {
|
||||
gridArea: 'input',
|
||||
margin: '5px 0',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<MetaProvider>
|
||||
<SiteContextProvider>
|
||||
<Toaster
|
||||
containerStyle={
|
||||
props.location?.pathname.indexOf('/login') >= 0
|
||||
? null
|
||||
: { 'margin-top': '60px' }
|
||||
}
|
||||
/>
|
||||
{props.children}
|
||||
</SiteContextProvider>
|
||||
</MetaProvider>
|
||||
<MantineProvider withNormalizeCSS theme={gTheme}>
|
||||
<AuthProvider>
|
||||
<ModalsProvider>
|
||||
<Notifications position="top-right" mt={60} autoClose={3000} />
|
||||
<RouterProvider router={router} />
|
||||
</ModalsProvider>
|
||||
</AuthProvider>
|
||||
</MantineProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1,68 +0,0 @@
|
||||
#root {
|
||||
margin: 0 auto;
|
||||
|
||||
--white: #fff;
|
||||
--black: #212121;
|
||||
--primary: #03c9d7;
|
||||
--green: #05b187;
|
||||
--orange: #fb9678;
|
||||
--yellow: #fec90f;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#main-page {
|
||||
height: calc(100svh - 64px);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#main-page .main-content {
|
||||
max-height: calc(100svh - 64px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
.scroll-shadow-horizontal {
|
||||
background:
|
||||
linear-gradient(90deg,white 33%,rgba(255,255,255,0)),
|
||||
linear-gradient(90deg,rgba(255,255,255,0),white 66%) 0 100%,
|
||||
radial-gradient(farthest-side at 0 50%,rgba(0,0,0,.1),transparent),
|
||||
radial-gradient(farthest-side at 100% 50%,rgba(0,0,0,.1),transparent) 0 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
background-position: 0 0,100%,0 0,100%;
|
||||
background-size: 20px 100%, 20px 100%, 10px 100%, 10px 100%;
|
||||
}
|
||||
|
||||
.scroll-shadow-vertical {
|
||||
background:
|
||||
linear-gradient(white 30%, rgba(255, 255, 255, 0)) center top,
|
||||
linear-gradient(rgba(255, 255, 255, 0), white 70%) center bottom,
|
||||
radial-gradient(farthest-side at 50% 0, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0)) center top,
|
||||
radial-gradient(farthest-side at 50% 100%, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0)) center bottom;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea {
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
outline-color: transparent !important;
|
||||
}
|
||||
}
|
1
frontend/src/_mantine.scss
Normal file
1
frontend/src/_mantine.scss
Normal file
@ -0,0 +1 @@
|
||||
// for mixin
|
@ -1,29 +1,65 @@
|
||||
import useSWR from 'swr'
|
||||
import useSWRMutation from 'swr/mutation'
|
||||
import { protocol } from './index'
|
||||
import {
|
||||
DEL_DELETE_HOUSE,
|
||||
GET_HOUSE_DETAIL,
|
||||
GET_HOUSES_LIST,
|
||||
POST_HOUSE_CREATE,
|
||||
PUT_UPDATE_HOUSE,
|
||||
} from './url'
|
||||
|
||||
export const postCreateHouse = (payload) => {
|
||||
const getAllHouse = async ({ page, pageSize }) => {
|
||||
const url = GET_HOUSES_LIST({ page, pageSize })
|
||||
const response = await protocol.get(url, {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
const getHouseDetail = async (id) => {
|
||||
const url = GET_HOUSE_DETAIL(id)
|
||||
const response = await protocol.get(url, {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
const postCreateHouse = (payload) => {
|
||||
return protocol.post(POST_HOUSE_CREATE, payload)
|
||||
}
|
||||
|
||||
export const getAllHouse = ({ page, pageSize }) => {
|
||||
return protocol.get(
|
||||
GET_HOUSES_LIST({
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
export const getHouseDetail = (id) => {
|
||||
return protocol.get(GET_HOUSE_DETAIL(id), {})
|
||||
}
|
||||
|
||||
export const putUpdateHouse = (payload) => {
|
||||
const putUpdateHouse = (payload) => {
|
||||
return protocol.put(PUT_UPDATE_HOUSE, payload)
|
||||
}
|
||||
|
||||
const putDeleteHouse = (_action, { arg: id }) => {
|
||||
return protocol.delete(DEL_DELETE_HOUSE(id))
|
||||
}
|
||||
|
||||
const createOrUpdateHouse = (_action, { arg }) => {
|
||||
const isUpdate = !!arg.id
|
||||
const fnWait = isUpdate ? putUpdateHouse : postCreateHouse
|
||||
|
||||
return fnWait(arg)
|
||||
}
|
||||
|
||||
export function useHouse(page, pageSize) {
|
||||
const swrObj = useSWR({ page, pageSize }, getAllHouse)
|
||||
const { trigger, isMutating } = useSWRMutation('action', putDeleteHouse)
|
||||
|
||||
return {
|
||||
...swrObj,
|
||||
houses: swrObj.data ?? [],
|
||||
trigger,
|
||||
isMutating,
|
||||
}
|
||||
}
|
||||
|
||||
export function useHouseDetail(id) {
|
||||
const swrObj = useSWR(id, getHouseDetail)
|
||||
const { trigger, isMutating } = useSWRMutation('action', createOrUpdateHouse)
|
||||
|
||||
return {
|
||||
...swrObj,
|
||||
house: swrObj.data ?? {},
|
||||
trigger,
|
||||
isMutating,
|
||||
}
|
||||
}
|
||||
|
@ -12,3 +12,4 @@ export const GET_HOUSES_LIST = ({ page, pageSize }) =>
|
||||
`/api/house/all?page=${page}&pageSize=${pageSize}`
|
||||
export const GET_HOUSE_DETAIL = (id) => `/api/house/${id}`
|
||||
export const PUT_UPDATE_HOUSE = '/api/house/update'
|
||||
export const DEL_DELETE_HOUSE = (id) => `/api/house/delete/${id}`
|
||||
|
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
After Width: | Height: | Size: 4.0 KiB |
@ -1,228 +0,0 @@
|
||||
import ConfirmPopup from '@components/common/ConfirmPopup'
|
||||
import Popup from '@components/common/Popup'
|
||||
import TextInput from '@components/common/TextInput'
|
||||
import Textarea from '@components/common/Textarea'
|
||||
import { createForm } from '@felte/solid'
|
||||
import { validator } from '@felte/validator-yup'
|
||||
import useLanguage from '@hooks/useLanguage'
|
||||
import {
|
||||
IconAddressBook,
|
||||
IconCirclePlus,
|
||||
IconEditCircle,
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconVector,
|
||||
} from '@tabler/icons-solidjs'
|
||||
import {
|
||||
createComponent,
|
||||
createEffect,
|
||||
createSignal,
|
||||
For,
|
||||
Show,
|
||||
untrack,
|
||||
} from 'solid-js'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import * as yup from 'yup'
|
||||
import AreaItem from './AreaItem'
|
||||
|
||||
/**
|
||||
* Returns a Yup schema object for validating an area object.
|
||||
*
|
||||
* @param {Object} language - An object containing the language settings.
|
||||
* @param {Function} isRequired - A function that returns a validation message for required fields.
|
||||
* @return {Object} A Yup schema object with two required fields: name and description.
|
||||
*/
|
||||
const areaSchema = (language, isRequired) =>
|
||||
yup.object({
|
||||
name: yup
|
||||
.string()
|
||||
.min(3, language.message['MIN_THREE_CHAR'])
|
||||
.required(isRequired(language.ui.areaName)),
|
||||
desc: yup
|
||||
.string()
|
||||
.min(3, language.message['MIN_THREE_CHAR'])
|
||||
.required(isRequired(language.ui.areaName)),
|
||||
})
|
||||
|
||||
export default function AreaAdd(props) {
|
||||
const [openModal, setOpenModal] = createSignal(false)
|
||||
const [dataArea, setDataArea] = createSignal([])
|
||||
const [editMode, setEditMode] = createSignal(false)
|
||||
const { language, isRequired } = useLanguage()
|
||||
const { form, reset, data, setData, errors, createSubmitHandler } =
|
||||
createForm({
|
||||
extend: [validator({ schema: areaSchema(language, isRequired) })],
|
||||
onSubmit: async (values) => {
|
||||
values.id = uuidv4()
|
||||
values.isCreate = true
|
||||
setDataArea((prev) => [...prev, values])
|
||||
onModalClose()
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.value && dataArea().length === 0) {
|
||||
console.log(props.value)
|
||||
untrack(() => setDataArea(props.value))
|
||||
}
|
||||
})
|
||||
|
||||
const onModalClose = () => {
|
||||
setOpenModal(false), reset(), setEditMode(false)
|
||||
}
|
||||
|
||||
const onOpenModal = () => {
|
||||
setOpenModal(true)
|
||||
}
|
||||
|
||||
const onConfirmDelete = (delId) => {
|
||||
setDataArea((prev) => {
|
||||
prev = prev.filter((item) => item.id !== delId)
|
||||
return [...prev]
|
||||
})
|
||||
props.setData('areas', dataArea())
|
||||
}
|
||||
|
||||
const onClickEditItem = (id) => {
|
||||
const editItem = dataArea().find((item) => item.id === id)
|
||||
setData(editItem)
|
||||
setEditMode(true)
|
||||
setOpenModal(true)
|
||||
}
|
||||
|
||||
const onClickUpdateItem = createSubmitHandler({
|
||||
onSubmit: (values) => {
|
||||
setDataArea((prev) => {
|
||||
prev = prev.map((item) => {
|
||||
if (item.id === values.id) {
|
||||
return values
|
||||
}
|
||||
return item
|
||||
})
|
||||
return [...prev]
|
||||
})
|
||||
props.setData('areas', dataArea())
|
||||
reset()
|
||||
setEditMode(false)
|
||||
onModalClose()
|
||||
},
|
||||
})
|
||||
|
||||
const onClickDeleteItem = (delId) => {
|
||||
createComponent(ConfirmPopup, {
|
||||
title: language?.message['CONFIRM_DELETE'],
|
||||
children: language?.message['CONFIRM_DELETE_NOTE'],
|
||||
deleteId: delId,
|
||||
onConfirm: onConfirmDelete,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="form-control mb-3">
|
||||
<div class="join join-vertical">
|
||||
<div
|
||||
class="flex items-center justify-between bg-base-200 border border-gray-300 h-12 px-3 join-item"
|
||||
classList={{ 'border-red-500': props.error }}
|
||||
>
|
||||
<label class="label justify-start gap-2">
|
||||
<IconVector size={18} />
|
||||
<span class="label-text font-bold whitespace-nowrap">
|
||||
{language.ui.areas}
|
||||
</span>
|
||||
<Show when={props.error}>
|
||||
<div
|
||||
class="tooltip tooltip-right tooltip-error before:text-white text-red-500"
|
||||
data-tip={props.error}
|
||||
>
|
||||
<IconInfoCircle size={18} />
|
||||
</div>
|
||||
</Show>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-circle btn-xs text-green-400 hover:bg-green-100 hover:text-green-500"
|
||||
onClick={onOpenModal}
|
||||
>
|
||||
<IconCirclePlus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="scroll-shadow-vertical no-scrollbar border border-gray-300 join-item"
|
||||
classList={{ 'border-red-500': props.error }}
|
||||
>
|
||||
<div class="p-3 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Show when={dataArea().length > 0} fallback={language.ui.empty}>
|
||||
<For each={dataArea()}>
|
||||
{(item, index) => (
|
||||
<AreaItem
|
||||
data={item}
|
||||
formName={props.name}
|
||||
num={index()}
|
||||
onDelete={onClickDeleteItem}
|
||||
onEdit={onClickEditItem}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Popup
|
||||
icon={
|
||||
editMode() ? (
|
||||
<IconEditCircle size={20} class="text-blue-500" />
|
||||
) : (
|
||||
<IconCirclePlus size={20} class="text-green-500" />
|
||||
)
|
||||
}
|
||||
title={editMode() ? language.ui.editArea : language.ui.addArea}
|
||||
titleClass="text-lg"
|
||||
openModal={openModal()}
|
||||
onModalClose={onModalClose}
|
||||
class="!w-6/12 !max-w-5xl"
|
||||
>
|
||||
<form autocomplete="off" use:form>
|
||||
<div class="modal-body mt-4">
|
||||
<TextInput
|
||||
icon={IconAddressBook}
|
||||
name="name"
|
||||
label={language.ui.areaName}
|
||||
placeholder={language.ui.areaName}
|
||||
value={data('name')}
|
||||
error={errors('name')}
|
||||
/>
|
||||
<Textarea
|
||||
icon={IconFileDescription}
|
||||
name="desc"
|
||||
label={language.ui.areaDesc}
|
||||
placeholder={language.ui.areaDesc}
|
||||
value={data('desc')}
|
||||
error={errors('desc')}
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<Show
|
||||
when={editMode()}
|
||||
fallback={
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{language.ui.save}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
onClick={onClickUpdateItem}
|
||||
>
|
||||
{language.ui.update}
|
||||
</button>
|
||||
</Show>
|
||||
<button type="button" class="btn btn-ghost" onClick={onModalClose}>
|
||||
{language.ui.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Popup>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,54 +0,0 @@
|
||||
import { IconPencil, IconTrash } from '@tabler/icons-solidjs'
|
||||
import { Show } from 'solid-js'
|
||||
|
||||
export default function AreaItem(props) {
|
||||
const data = () => props.data
|
||||
|
||||
return (
|
||||
<div class="flex justify-between shadow rounded-lg p-3 border border-gray-300">
|
||||
<div class="item-body">
|
||||
<span class="text-md font-bold">{data().name}</span>
|
||||
<p class="text-xs">{data().desc}</p>
|
||||
<Show when={!data().isCreate}>
|
||||
<input
|
||||
type="hidden"
|
||||
name={`${props.formName}.${props.num}.id`}
|
||||
value={data().id}
|
||||
/>
|
||||
</Show>
|
||||
<input
|
||||
type="hidden"
|
||||
name={`${props.formName}.${props.num}.name`}
|
||||
value={data().name}
|
||||
data-value={data().name}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name={`${props.formName}.${props.num}.desc`}
|
||||
value={data().desc}
|
||||
data-value={data().desc}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<Show when={props.onEdit}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-circle btn-ghost btn-sm text-blue-500 hover:bg-blue-100"
|
||||
onClick={() => props.onEdit(data().id)}
|
||||
>
|
||||
<IconPencil size={20} />
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={props.onDelete}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-circle btn-ghost btn-sm text-red-500 hover:bg-red-100"
|
||||
onClick={() => props.onDelete(data().id)}
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export * from './AreaAdd'
|
||||
export { default } from './AreaAdd'
|
181
frontend/src/components/AreaInput.jsx
Normal file
181
frontend/src/components/AreaInput.jsx
Normal file
@ -0,0 +1,181 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Grid,
|
||||
Group,
|
||||
InputWrapper,
|
||||
Modal,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from '@mantine/core'
|
||||
import { useForm, yupResolver } from '@mantine/form'
|
||||
import { useDisclosure, useMediaQuery } from '@mantine/hooks'
|
||||
import {
|
||||
IconAddressBook,
|
||||
IconCirclePlus,
|
||||
IconFileDescription,
|
||||
} from '@tabler/icons-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import * as yup from 'yup'
|
||||
import AreaItem from './AreaItem'
|
||||
import { errorTip, labelWithIcon } from './Common'
|
||||
|
||||
const areaSchema = (t) =>
|
||||
yup.object({
|
||||
name: yup
|
||||
.string()
|
||||
.min(3, t('message_min_three_char'))
|
||||
.required(t('message_is_required', { field: t('ui_area_name') })),
|
||||
desc: yup
|
||||
.string()
|
||||
.min(3, t('message_min_three_char'))
|
||||
.required(t('message_is_required', { field: t('ui_area_desc') })),
|
||||
})
|
||||
|
||||
export default function AreaInput(props) {
|
||||
const { t } = useTranslation()
|
||||
const [editData, setEditData] = useState(null)
|
||||
const [opened, { open, close }] = useDisclosure(false)
|
||||
const isMobile = useMediaQuery('(max-width: 62em)')
|
||||
const { value, onChange, error } = props
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: '',
|
||||
desc: '',
|
||||
},
|
||||
validate: yupResolver(areaSchema(t)),
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
error: errorTip(payload.inputProps.error),
|
||||
}),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (editData) {
|
||||
form.setValues(editData)
|
||||
}
|
||||
}, [editData])
|
||||
|
||||
const onCloseModal = () => {
|
||||
form.reset()
|
||||
setEditData(null)
|
||||
close()
|
||||
}
|
||||
|
||||
const addArea = (area) => {
|
||||
area.id = uuidv4()
|
||||
area.isCreate = true
|
||||
onChange([...value, area])
|
||||
onCloseModal()
|
||||
}
|
||||
|
||||
const updateArea = (area) => {
|
||||
onChange(value.map((item) => (item.id === area.id ? area : item)))
|
||||
form.reset()
|
||||
onCloseModal()
|
||||
}
|
||||
|
||||
const onEditArea = (area) => {
|
||||
setEditData(area)
|
||||
open()
|
||||
}
|
||||
|
||||
const onDeleteArea = (area) => {
|
||||
onChange(value.filter((item) => item.id !== area.id))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputWrapper label={props.label} error={error}>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
style={{ gridArea: 'right', justifySelf: 'end' }}
|
||||
onClick={open}
|
||||
>
|
||||
<IconCirclePlus size={20} color="var(--mantine-color-green-6)" />
|
||||
</ActionIcon>
|
||||
<Grid
|
||||
gutter="md"
|
||||
my={5}
|
||||
p="sm"
|
||||
bd={`1px solid ${error ? 'red.5' : 'gray.4'}`}
|
||||
style={{
|
||||
gridArea: 'input',
|
||||
borderRadius: 'var(--mantine-radius-default)',
|
||||
borderColor: '',
|
||||
}}
|
||||
>
|
||||
{value.length > 0 ? (
|
||||
value.map((area) => (
|
||||
<Grid.Col key={area.id} span={{ base: 12, md: 6, lg: 4 }}>
|
||||
<AreaItem
|
||||
data={area}
|
||||
onEdit={onEditArea}
|
||||
onDelete={onDeleteArea}
|
||||
/>
|
||||
</Grid.Col>
|
||||
))
|
||||
) : (
|
||||
<Grid.Col span={12}>
|
||||
<Text size="xs" fw={500}>
|
||||
{t('ui_empty')}
|
||||
</Text>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
</InputWrapper>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onCloseModal}
|
||||
title={labelWithIcon(t('ui_add_area'), IconCirclePlus, {
|
||||
color: 'green',
|
||||
})}
|
||||
centered
|
||||
radius="lg"
|
||||
fullScreen={isMobile}
|
||||
transitionProps={{ transition: 'fade', duration: 200 }}
|
||||
>
|
||||
<form autoComplete="off">
|
||||
<TextInput
|
||||
label={labelWithIcon(t('ui_area_name'), IconAddressBook)}
|
||||
placeholder={t('ui_area_name')}
|
||||
withAsterisk
|
||||
mb="md"
|
||||
data-autofocus
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<Textarea
|
||||
label={labelWithIcon(t('ui_area_desc'), IconFileDescription)}
|
||||
placeholder={t('ui_area_desc')}
|
||||
withAsterisk
|
||||
rows={4}
|
||||
{...form.getInputProps('desc')}
|
||||
/>
|
||||
<Group mt="md">
|
||||
{editData ? (
|
||||
<Button type="button" onClick={form.onSubmit(updateArea)}>
|
||||
{t('ui_update')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" onClick={form.onSubmit(addArea)}>
|
||||
{t('ui_save')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
type="button"
|
||||
onClick={onCloseModal}
|
||||
>
|
||||
{t('ui_cancel')}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
55
frontend/src/components/AreaItem.jsx
Normal file
55
frontend/src/components/AreaItem.jsx
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Card,
|
||||
Group,
|
||||
Text,
|
||||
TypographyStylesProvider,
|
||||
} from '@mantine/core'
|
||||
import { modals } from '@mantine/modals'
|
||||
import { IconPencil, IconTrash } from '@tabler/icons-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function AreaItem({ data, onEdit, onDelete }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const openConfirmModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: t('message_confirm_delete'),
|
||||
centered: true,
|
||||
children: (
|
||||
<TypographyStylesProvider fz={'sm'}>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('message_confirm_delete_note'),
|
||||
}}
|
||||
/>
|
||||
</TypographyStylesProvider>
|
||||
),
|
||||
labels: { confirm: t('ui_delete'), cancel: t('ui_cancel') },
|
||||
confirmProps: { color: 'red' },
|
||||
onConfirm: () => onDelete(data),
|
||||
})
|
||||
|
||||
const onClickEdit = () => onEdit(data)
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Text mb="sm" fz="sm">
|
||||
{data.name}
|
||||
</Text>
|
||||
<Text mb="sm" fz="sm">
|
||||
{data.desc}
|
||||
</Text>
|
||||
<Card.Section withBorder inheritPadding py="xs">
|
||||
<Group justify="flex-end">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={onClickEdit}>
|
||||
<IconPencil size={20} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" onClick={openConfirmModal}>
|
||||
<IconTrash size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
)
|
||||
}
|
18
frontend/src/components/Common.jsx
Normal file
18
frontend/src/components/Common.jsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Flex, Text, Tooltip } from '@mantine/core'
|
||||
import { IconInfoCircle } from '@tabler/icons-react'
|
||||
|
||||
export const errorTip = (message) =>
|
||||
message && (
|
||||
<Tooltip label={message} color="red" position="top-start">
|
||||
<IconInfoCircle size={15} color="red" />
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
export const labelWithIcon = (text, Icon = null, iconProps = {}) => (
|
||||
<Flex display="inline-flex" align="center" gap={5}>
|
||||
{Icon && <Icon size={15} color="black" {...iconProps} />}
|
||||
<Text size="sm" lh={1} fw={500}>
|
||||
{text}
|
||||
</Text>
|
||||
</Flex>
|
||||
)
|
42
frontend/src/components/Datatable.jsx
Normal file
42
frontend/src/components/Datatable.jsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { Box, Table } from '@mantine/core'
|
||||
|
||||
export default function Datatable({ columns, data, withCheckbox = false }) {
|
||||
const renderCols = columns.map((col) => (
|
||||
<Table.Th key={col.field} w={col.width}>
|
||||
{col.title}
|
||||
</Table.Th>
|
||||
))
|
||||
|
||||
const renderRows = data.map((row) => (
|
||||
<Table.Tr key={row.id}>
|
||||
{columns.map((col) => (
|
||||
<Table.Td key={col.field}>
|
||||
{col.render ? col.render(row) : row[col.field]}
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
))
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={(theme) => ({
|
||||
border: `1px solid ${theme.colors.gray[3]}`,
|
||||
overflow: 'hidden',
|
||||
borderRadius: '10px',
|
||||
})}
|
||||
>
|
||||
<Table
|
||||
striped
|
||||
highlightOnHover
|
||||
withColumnBorders
|
||||
bg="white"
|
||||
verticalSpacing="sm"
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr>{renderCols}</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>{renderRows}</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)
|
||||
}
|
41
frontend/src/components/GridList.jsx
Normal file
41
frontend/src/components/GridList.jsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { Box, Card, Center, SimpleGrid } from '@mantine/core'
|
||||
|
||||
export default function GridList({ columns, data }) {
|
||||
return (
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: 2, lg: 4 }}
|
||||
pb="md"
|
||||
styles={{
|
||||
root: {
|
||||
borderBottom: '1px solid var(--mantine-color-default-border)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{data.map((row) => (
|
||||
<Card key={row.id} shadow="md" radius="lg" withBorder>
|
||||
{columns.map((col) => {
|
||||
if (col.field === 'icon') {
|
||||
return (
|
||||
<Card.Section key={col.field} p="lg">
|
||||
<Center>{col.render(row, true)}</Center>
|
||||
</Card.Section>
|
||||
)
|
||||
}
|
||||
if (col.field === 'action') {
|
||||
return (
|
||||
<Card.Section key={col.field} withBorder inheritPadding py="xs">
|
||||
{col.render(row, true)}
|
||||
</Card.Section>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Box key={col.field} mb="md">
|
||||
{col.render(row, true)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)
|
||||
}
|
@ -1,98 +1,131 @@
|
||||
import { getProfile } from '@api/user'
|
||||
import Logo from '@assets/images/logo.svg'
|
||||
import { useSiteContext } from '@context/SiteContext'
|
||||
import useAuth from '@hooks/useAuth'
|
||||
import useToast from '@hooks/useToast'
|
||||
import { A } from '@solidjs/router'
|
||||
import { IconLogout, IconMenuDeep, IconUserCircle } from '@tabler/icons-solidjs'
|
||||
import { Helpers } from '@utils/helper'
|
||||
import { Show, onMount } from 'solid-js'
|
||||
import { useAuth, useSignInUp } from '@hooks/useAuth'
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Burger,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Menu,
|
||||
Text,
|
||||
} from '@mantine/core'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { PathConstants } from '@routes/routes'
|
||||
import {
|
||||
IconAt,
|
||||
IconCircleXFilled,
|
||||
IconLogout,
|
||||
IconUserCircle,
|
||||
} from '@tabler/icons-react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function Header() {
|
||||
const { store, setAuth, setUser } = useSiteContext()
|
||||
const { clickLogOut } = useAuth(setAuth)
|
||||
const notify = useToast()
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const resp = await getProfile()
|
||||
if (resp.status === 200) {
|
||||
setUser(resp.data)
|
||||
}
|
||||
} catch (error) {
|
||||
notify.error({
|
||||
title: 'Get profile fail!',
|
||||
closable: false,
|
||||
description: error?.data || 'Can not get user profile!',
|
||||
})
|
||||
async function getData(t, setUserInfo) {
|
||||
try {
|
||||
const resp = await getProfile()
|
||||
if (resp.status === 200) {
|
||||
setUserInfo(resp.data)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
color: 'red.5',
|
||||
icon: <IconCircleXFilled />,
|
||||
withCloseButton: true,
|
||||
title: t('message_api_call_fail'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default function Header({ opened, onClick }) {
|
||||
const { auth, setAuth, setUserInfo } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
const { onLogout } = useSignInUp(setAuth)
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.isLogged) {
|
||||
getData(t, setUserInfo)
|
||||
}
|
||||
}, [auth.isLogged])
|
||||
|
||||
const logOut = async () => {
|
||||
try {
|
||||
await clickLogOut()
|
||||
await onLogout()
|
||||
} catch (error) {
|
||||
notify.error({
|
||||
title: 'Logout fail!',
|
||||
closable: false,
|
||||
notifications.show({
|
||||
color: 'red.5',
|
||||
icon: <IconCircleXFilled />,
|
||||
withCloseButton: true,
|
||||
title: t('message_logout_fail'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header class="w-full navbar py-3 px-4 items-center justify-between bg-fu-primary">
|
||||
<div class="flex-1">
|
||||
<A href="/" class="text-white flex items-center hover:text-white">
|
||||
<img src={Logo} class="w-8" alt="Logo" />
|
||||
<span class="ml-2 text-2xl">Fuware</span>
|
||||
</A>
|
||||
</div>
|
||||
<Show when={store.auth}>
|
||||
<div class="flex-initial">
|
||||
<div class="dropdown dropdown-end hidden lg:flex">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="btn btn-ghost btn-circle w-10 h-10 min-h-10 avatar"
|
||||
>
|
||||
<div class="w-8 rounded-full">
|
||||
<img
|
||||
src={`https://ui-avatars.com/api/?name=${store.userInfo?.name}&background=fb9678`}
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="mt-12 z-[1] p-2 shadow-md menu menu-sm dropdown-content bg-base-100 rounded-box w-52"
|
||||
>
|
||||
<li class="border rounded-full mb-1">
|
||||
<span class="menu-title text-black font-bold !py-1">
|
||||
{store.userInfo?.name}
|
||||
</span>
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<A href={Helpers.getRoutePath('profile')}>
|
||||
<IconUserCircle size={15} />
|
||||
Profile
|
||||
</A>
|
||||
</li>
|
||||
<li>
|
||||
<a onClick={logOut}>
|
||||
<IconLogout size={15} />
|
||||
Logout
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<label
|
||||
for="nav-menu"
|
||||
class="btn btn-ghost btn-sm drawer-button pr-0 lg:hidden"
|
||||
<Flex h="100%" p="md" bg="cyan" justify="space-between">
|
||||
<Link to="/">
|
||||
<Group gap={10}>
|
||||
<Image src={Logo} alt="logo" h={30} />
|
||||
<Text c="white" size="xl" lh="1" fw="bold">
|
||||
Fuware
|
||||
</Text>
|
||||
</Group>
|
||||
</Link>
|
||||
{auth.isLogged && (
|
||||
<Flex align="center" gap={10}>
|
||||
<Menu
|
||||
trigger="hover"
|
||||
position="bottom-end"
|
||||
width={150}
|
||||
visibleFrom="md"
|
||||
>
|
||||
<IconMenuDeep size={25} color="white" />
|
||||
</label>
|
||||
</div>
|
||||
</Show>
|
||||
</header>
|
||||
<Menu.Target>
|
||||
<Avatar
|
||||
size="30"
|
||||
variant="filled"
|
||||
name={auth.userInfo?.name}
|
||||
color="red.4"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
<Badge
|
||||
leftSection={<IconAt size={12} />}
|
||||
size="sm"
|
||||
color="yellow.8"
|
||||
>
|
||||
{auth.userInfo?.name}
|
||||
</Badge>
|
||||
</Menu.Label>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconUserCircle size={15} />}
|
||||
component={Link}
|
||||
to={PathConstants.PROFILE}
|
||||
>
|
||||
{t('ui_profile')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconLogout size={15} />}
|
||||
fw="500"
|
||||
onClick={logOut}
|
||||
>
|
||||
{t('ui_logout')}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<Burger
|
||||
opened={opened}
|
||||
onClick={onClick}
|
||||
color="white"
|
||||
hiddenFrom="md"
|
||||
size="sm"
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
128
frontend/src/components/Navbar.jsx
Normal file
128
frontend/src/components/Navbar.jsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { useAuth, useSignInUp } from '@hooks/useAuth'
|
||||
import { Avatar, Badge, Box, Divider, Flex, Menu, NavLink } from '@mantine/core'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { NAV_ITEM } from '@routes/nav-route'
|
||||
import { PathConstants } from '@routes/routes'
|
||||
import {
|
||||
IconAt,
|
||||
IconCircleXFilled,
|
||||
IconLogout,
|
||||
IconUserCircle,
|
||||
} from '@tabler/icons-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NavLink as Link } from 'react-router-dom'
|
||||
|
||||
function NavLinkWithChildren(item) {
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<NavLink label={item.text} leftSection={<Icon size={15} />}>
|
||||
{item.children.map((child) => {
|
||||
if (!child.show) return
|
||||
if (child.children) {
|
||||
return <NavLinkWithChildren key={child.pathName} {...child} />
|
||||
} else {
|
||||
return (
|
||||
<NavLink
|
||||
key={child.pathName}
|
||||
leftSection={<Icon size={15} />}
|
||||
label={child.text}
|
||||
component={Link}
|
||||
to={child.pathName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Navbar() {
|
||||
const { auth, setAuth } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
const { onLogout } = useSignInUp(setAuth)
|
||||
|
||||
const logOut = async () => {
|
||||
try {
|
||||
await onLogout()
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
color: 'red.5',
|
||||
icon: <IconCircleXFilled />,
|
||||
withCloseButton: true,
|
||||
title: t('message_logout_fail'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box hiddenFrom="md">
|
||||
<Flex p="lg">
|
||||
<Menu trigger="hover" position="bottom-start" width={150}>
|
||||
<Menu.Target>
|
||||
<Avatar
|
||||
size="30"
|
||||
variant="filled"
|
||||
name={auth.userInfo?.name}
|
||||
color="red.4"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
<Badge
|
||||
leftSection={<IconAt size={12} />}
|
||||
size="sm"
|
||||
color="yellow.8"
|
||||
>
|
||||
{auth.userInfo?.name}
|
||||
</Badge>
|
||||
</Menu.Label>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconUserCircle size={15} />}
|
||||
component={Link}
|
||||
to={PathConstants.PROFILE}
|
||||
>
|
||||
{t('ui_profile')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconLogout size={15} />}
|
||||
fw="500"
|
||||
onClick={logOut}
|
||||
>
|
||||
{t('ui_logout')}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Flex>
|
||||
<Divider />
|
||||
</Box>
|
||||
<Box>
|
||||
{NAV_ITEM(t, auth?.userInfo?.isAdmin).map((item) => {
|
||||
if (!item.show) return null
|
||||
const Icon = item.icon
|
||||
|
||||
if (item.children) {
|
||||
return <NavLinkWithChildren key={item.pathName} {...item} />
|
||||
} else {
|
||||
return (
|
||||
<NavLink
|
||||
key={item.pathName}
|
||||
leftSection={<Icon size={15} />}
|
||||
label={item.text}
|
||||
component={Link}
|
||||
to={item.pathName}
|
||||
className={({ isActive, isPending }) =>
|
||||
isPending ? 'pending' : isActive ? 'active' : ''
|
||||
}
|
||||
color="red.8"
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,167 +0,0 @@
|
||||
import { useSiteContext } from '@context/SiteContext'
|
||||
import useAuth from '@hooks/useAuth'
|
||||
import useLanguage from '@hooks/useLanguage'
|
||||
import { A } from '@solidjs/router'
|
||||
import {
|
||||
IconBuildingWarehouse,
|
||||
IconDashboard,
|
||||
IconMapPin,
|
||||
IconTriangle,
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { Helpers } from '@utils/helper'
|
||||
import { For, Show, mergeProps } from 'solid-js'
|
||||
import { Dynamic } from 'solid-js/web'
|
||||
import './navbar.scss'
|
||||
|
||||
const { language } = useLanguage()
|
||||
|
||||
export const NAV_ITEM = (admin = false) => [
|
||||
{
|
||||
pathName: 'dashboard',
|
||||
show: admin,
|
||||
icon: IconDashboard,
|
||||
text: language?.ui.dashboard,
|
||||
},
|
||||
{
|
||||
pathName: 'location',
|
||||
show: true,
|
||||
icon: IconMapPin,
|
||||
text: language?.ui.house,
|
||||
},
|
||||
{
|
||||
pathName: 'warehouse',
|
||||
show: true,
|
||||
icon: IconBuildingWarehouse,
|
||||
text: language?.ui.warehouse,
|
||||
},
|
||||
]
|
||||
|
||||
function NavbarItem(props) {
|
||||
const merged = mergeProps({ active: true }, props)
|
||||
return (
|
||||
<Show
|
||||
when={merged.active && merged.pathName}
|
||||
fallback={
|
||||
<>
|
||||
<Dynamic component={merged.icon} />
|
||||
{merged.text}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<A
|
||||
class="hover:text-fu-black"
|
||||
href={Helpers.getRoutePath(merged.pathName)}
|
||||
>
|
||||
<Dynamic component={merged.icon} />
|
||||
{merged.text}
|
||||
</A>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function NavbarWithChildren(props) {
|
||||
return (
|
||||
<li class="mb-2">
|
||||
<h2 class="menu-title flex items-center gap-2">
|
||||
<NavbarItem {...props} active={false} />
|
||||
</h2>
|
||||
<ul class="pl-4">
|
||||
<For each={props.children}>
|
||||
{(child) => {
|
||||
if (!child.show) return
|
||||
if (child.children) {
|
||||
return <NavbarWithChildren {...child} />
|
||||
} else {
|
||||
return (
|
||||
<li class="mb-2">
|
||||
<NavbarItem {...child} />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Navbar() {
|
||||
const { store, setAuth } = useSiteContext()
|
||||
const { clickLogOut } = useAuth(setAuth)
|
||||
|
||||
const logOut = async () => {
|
||||
try {
|
||||
await clickLogOut()
|
||||
} catch (error) {
|
||||
console.log({
|
||||
status: 'danger',
|
||||
title: 'Logout fail!',
|
||||
closable: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="drawer-side lg:h-[calc(100svh-64px)]">
|
||||
<label for="nav-menu" aria-label="close sidebar" class="drawer-overlay" />
|
||||
<div class="bg-base-200 w-80 max-w-[90vw] min-h-full">
|
||||
<Show when={store.auth}>
|
||||
<div class="flex items-center px-3 pt-3 lg:hidden">
|
||||
<div class="dropdown dropdown-start flex mr-2">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="btn btn-ghost btn-circle w-10 h-10 min-h-10 avatar"
|
||||
>
|
||||
<div class="w-8 rounded-full">
|
||||
<img
|
||||
src={`https://ui-avatars.com/api/?name=${store.userInfo?.name}&background=fb9678`}
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="mt-12 z-[1] p-2 shadow-md menu menu-sm dropdown-content bg-base-100 rounded-box w-52"
|
||||
>
|
||||
<li class="border rounded-full mb-1">
|
||||
<span class="menu-title text-black font-bold !py-1">
|
||||
{store.userInfo?.name}
|
||||
</span>
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<A href={Helpers.getRoutePath('profile')}>Profile</A>
|
||||
</li>
|
||||
<li>
|
||||
<a onClick={logOut}>Logout</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<span class="text-black font-bold text-xl">
|
||||
{store.userInfo?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div class="divider divider-success mb-0 mt-2 lg:hidden">
|
||||
<IconTriangle size={30} />
|
||||
</div>
|
||||
</Show>
|
||||
<ul class="menu w-full text-base-content pt-3 px-3 pb-6">
|
||||
<For each={NAV_ITEM(store?.userInfo?.isAdmin)}>
|
||||
{(item) => {
|
||||
if (!item.show) return
|
||||
if (item.children) {
|
||||
return <NavbarWithChildren {...item} />
|
||||
} else {
|
||||
return (
|
||||
<li class="mb-2">
|
||||
<NavbarItem {...item} />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export * from './Navbar'
|
||||
export { default } from './Navbar'
|
@ -1,62 +0,0 @@
|
||||
import {
|
||||
IconCircleCheck,
|
||||
IconFaceIdError,
|
||||
IconInfoCircle,
|
||||
IconX,
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { Show } from 'solid-js'
|
||||
import { Dynamic } from 'solid-js/web'
|
||||
|
||||
const STATUS = Object.freeze(
|
||||
new Proxy(
|
||||
{
|
||||
success: {
|
||||
icon: IconCircleCheck,
|
||||
color: 'text-green-500',
|
||||
},
|
||||
error: {
|
||||
icon: IconFaceIdError,
|
||||
color: 'text-red-500',
|
||||
},
|
||||
info: {
|
||||
icon: IconInfoCircle,
|
||||
color: 'text-blue-500',
|
||||
},
|
||||
},
|
||||
{
|
||||
get: (target, prop) =>
|
||||
target[prop] ?? { icon: IconInfoCircle, color: 'text-blue-500' },
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export default function Notify(props) {
|
||||
return (
|
||||
<div class="bg-white border border-slate-300 w-max h-20 shadow-lg rounded-md gap-4 p-4 flex flex-row items-center justify-center">
|
||||
<section class="w-6 h-full flex flex-col items-center justify-start">
|
||||
<Dynamic
|
||||
component={STATUS[props.status].icon}
|
||||
size={30}
|
||||
class={STATUS[props.status].color}
|
||||
/>
|
||||
</section>
|
||||
<section class="h-full flex flex-col items-start justify-end gap-1">
|
||||
<Show when={props.title}>
|
||||
<h1
|
||||
class={`text-base font-semibold text-zinc-800 antialiased ${STATUS[props.status].color}`}
|
||||
>
|
||||
{props.title}
|
||||
</h1>
|
||||
</Show>
|
||||
<p class="text-sm font-medium text-black antialiased">
|
||||
{props.description}
|
||||
</p>
|
||||
</section>
|
||||
<Show when={props.onClose}>
|
||||
<section class="w-5 h-full flex flex-col items-center justify-start">
|
||||
<IconX size={20} class="cursor-pointer" onclick={props.onClose} />
|
||||
</section>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
import { IconGridDots, IconLayoutList } from '@tabler/icons-solidjs'
|
||||
import { createSignal } from 'solid-js'
|
||||
|
||||
export const VIEWDATA = Object.freeze(
|
||||
new Proxy(
|
||||
{ list: 'list', grid: 'grid' },
|
||||
{
|
||||
get: (target, prop) => target[prop] ?? 'list',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export default function ViewSwitch(props) {
|
||||
const [view, setView] = createSignal(VIEWDATA['list'])
|
||||
|
||||
const selectView = (view, event) => {
|
||||
event.preventDefault()
|
||||
setView(view)
|
||||
props.switchView && props.switchView(view)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="view-switch join">
|
||||
<button
|
||||
class="btn join-item btn-sm"
|
||||
classList={{ 'btn-primary': view() === VIEWDATA['list'] }}
|
||||
onClick={[selectView, VIEWDATA['list']]}
|
||||
>
|
||||
<IconLayoutList size={15} />
|
||||
</button>
|
||||
<button
|
||||
class="btn join-item btn-sm"
|
||||
classList={{ 'btn-primary': view() === VIEWDATA['grid'] }}
|
||||
onClick={[selectView, VIEWDATA['grid']]}
|
||||
>
|
||||
<IconGridDots size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export * from './ViewSwitch'
|
||||
export { default } from './ViewSwitch'
|
@ -1,45 +0,0 @@
|
||||
import useLanguage from '@hooks/useLanguage'
|
||||
import { IconAlertTriangle } from '@tabler/icons-solidjs'
|
||||
import { Show, createSignal } from 'solid-js'
|
||||
import Popup from '../Popup'
|
||||
|
||||
export default function ConfirmPopup(props) {
|
||||
const [openModal, setOpenModal] = createSignal(true)
|
||||
const { language } = useLanguage()
|
||||
|
||||
const onConfirm = () => {
|
||||
props.onConfirm(props.deleteId)
|
||||
setOpenModal(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={openModal()}>
|
||||
<Popup
|
||||
icon={<IconAlertTriangle size={20} class="text-red-500" />}
|
||||
title={props.title}
|
||||
titleClass="text-md"
|
||||
openModal={openModal()}
|
||||
onModalClose={() => setOpenModal(false)}
|
||||
class="!w-4/12 !max-w-4xl"
|
||||
>
|
||||
<div class="modal-body">{props.children}</div>
|
||||
<div class="modal-action">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-sm"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{language.ui.confirm}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
onClick={() => setOpenModal(false)}
|
||||
>
|
||||
{language.ui.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</Popup>
|
||||
</Show>
|
||||
)
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export * from './ConfirmPopup'
|
||||
export { default } from './ConfirmPopup'
|
@ -1,69 +0,0 @@
|
||||
import { DOTS, usePagination } from '@hooks/usePagination'
|
||||
import { For, Show, splitProps } from 'solid-js'
|
||||
|
||||
export default function Pagination(props) {
|
||||
const [localProps, onEvents] = splitProps(
|
||||
props,
|
||||
['currentPage', 'totalCount', 'pageSize'],
|
||||
['onPageChange'],
|
||||
)
|
||||
|
||||
const paginationRange = usePagination(localProps)
|
||||
|
||||
const onNext = () => {
|
||||
onEvents.onPageChange(localProps.currentPage() + 1)
|
||||
}
|
||||
|
||||
const onPrevious = () => {
|
||||
onEvents.onPageChange(localProps.currentPage() - 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={localProps.currentPage !== 0 || paginationRange().length > 1}>
|
||||
<div class="pagination join">
|
||||
<button
|
||||
class="join-item btn btn-sm border border-gray-300"
|
||||
disabled={localProps.currentPage() === 1}
|
||||
onClick={onPrevious}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<For each={paginationRange()}>
|
||||
{(page) => {
|
||||
if (page === DOTS) {
|
||||
return (
|
||||
<button
|
||||
class="join-item btn btn-sm hidden lg:block border !border-gray-300"
|
||||
disabled
|
||||
>
|
||||
...
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
class="join-item btn btn-sm hidden lg:block border border-gray-300"
|
||||
classList={{
|
||||
'!block btn-primary': page === localProps.currentPage(),
|
||||
}}
|
||||
onClick={[onEvents.onPageChange, page]}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<button
|
||||
class="join-item btn btn-sm border border-gray-300"
|
||||
disabled={
|
||||
localProps.currentPage() ===
|
||||
Math.ceil(localProps.totalCount() / localProps.pageSize())
|
||||
}
|
||||
onClick={onNext}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export * from './Pagination'
|
||||
export { default } from './Pagination'
|
@ -1,27 +0,0 @@
|
||||
import { IconX } from '@tabler/icons-solidjs'
|
||||
import { Dynamic, Portal } from 'solid-js/web'
|
||||
|
||||
export default function Popup(props) {
|
||||
return (
|
||||
<Portal mount={document.getElementById('modal-portal')}>
|
||||
<div class="modal" classList={{ 'modal-open': props.openModal }}>
|
||||
<div class={`modal-box ${props.class || ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||
onClick={() => props.onModalClose()}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
<h3
|
||||
class={`flex jutify-center items-center gap-2 text-lg font-bold ${props.titleClass || ''}`}
|
||||
>
|
||||
<Dynamic component={props.icon} />
|
||||
{props.title}
|
||||
</h3>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export * from './Popup'
|
||||
export { default } from './Popup'
|
@ -1,41 +0,0 @@
|
||||
import { IconInfoCircle } from '@tabler/icons-solidjs'
|
||||
import { Show, splitProps } from 'solid-js'
|
||||
import { Dynamic } from 'solid-js/web'
|
||||
|
||||
export default function Textinput(props) {
|
||||
const [local, rest] = splitProps(props, ['label', 'icon'])
|
||||
|
||||
return (
|
||||
<div class="form-control w-full [&:not(:last-child)]:pb-3">
|
||||
<div class="join join-vertical">
|
||||
<div
|
||||
class="flex items-center justify-between bg-base-200 border border-gray-300 h-12 px-3 join-item"
|
||||
classList={{ 'border-red-500': props.error }}
|
||||
>
|
||||
<label class="label justify-start gap-2 bg-base-200">
|
||||
<Show when={local.icon && local.label}>
|
||||
<Dynamic component={local.icon} size={18} />
|
||||
<span class="label-text font-bold whitespace-nowrap">
|
||||
{local.label}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.error}>
|
||||
<div
|
||||
class="tooltip tooltip-right tooltip-error before:text-white text-red-500"
|
||||
data-tip={props.error}
|
||||
>
|
||||
<IconInfoCircle size={18} />
|
||||
</div>
|
||||
</Show>
|
||||
</label>
|
||||
{props.children}
|
||||
</div>
|
||||
<input
|
||||
{...rest}
|
||||
class="input input-bordered w-full join-item"
|
||||
classList={{ 'input-error': props.error }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { default } from './TextInput'
|
@ -1,39 +0,0 @@
|
||||
import { IconInfoCircle } from '@tabler/icons-solidjs'
|
||||
import { Show, splitProps } from 'solid-js'
|
||||
import { Dynamic } from 'solid-js/web'
|
||||
|
||||
export default function Textarea(props) {
|
||||
const [local, rest] = splitProps(props, ['label', 'icon'])
|
||||
|
||||
return (
|
||||
<label class="form-control w-full [&:not(:last-child)]:pb-3">
|
||||
<div class="join join-vertical">
|
||||
<label
|
||||
class="label justify-start gap-2 bg-base-200 border border-gray-300 h-12 px-3 join-item"
|
||||
classList={{ 'border-red-500': props.error }}
|
||||
>
|
||||
<Show when={local.icon && local.label}>
|
||||
<Dynamic component={local.icon} size={18} />
|
||||
<span class="label-text font-bold whitespace-nowrap">
|
||||
{local.label}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.error}>
|
||||
<div
|
||||
class="tooltip tooltip-right tooltip-error before:text-white text-red-500"
|
||||
data-tip={props.error}
|
||||
>
|
||||
<IconInfoCircle size={18} />
|
||||
</div>
|
||||
</Show>
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
{...rest}
|
||||
class={`textarea textarea-bordered h-24 w-full resize-none join-item ${props.class || ''}`}
|
||||
classList={{ 'textarea-error': props.error }}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export * from './Textarea'
|
||||
export { default } from './Textarea'
|
@ -1,52 +0,0 @@
|
||||
import { STORE_KEY } from '@utils/enum'
|
||||
import { Helpers } from '@utils/helper'
|
||||
import { createContext, onMount, useContext } from 'solid-js'
|
||||
import { createStore, produce } from 'solid-js/store'
|
||||
|
||||
export const SiteContext = createContext()
|
||||
|
||||
export function SiteContextProvider(props) {
|
||||
const [store, setStore] = createStore({
|
||||
auth: false,
|
||||
userInfo: null,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const storeData = Helpers.decrypt(localStorage.getItem(STORE_KEY))
|
||||
if (!storeData) return
|
||||
setStore(storeData)
|
||||
})
|
||||
|
||||
const setLocalStore = () => {
|
||||
if (store.auth) {
|
||||
localStorage.setItem(STORE_KEY, Helpers.encrypt(store))
|
||||
} else {
|
||||
localStorage.removeItem(STORE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
const setAuth = ({ auth, user }) => {
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.auth = auth
|
||||
s.userInfo = user
|
||||
}),
|
||||
)
|
||||
setLocalStore()
|
||||
}
|
||||
|
||||
const setUser = (user) => {
|
||||
setStore('userInfo', user)
|
||||
setLocalStore()
|
||||
}
|
||||
|
||||
return (
|
||||
<SiteContext.Provider value={{ store, setAuth, setUser }}>
|
||||
{props.children}
|
||||
</SiteContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSiteContext() {
|
||||
return useContext(SiteContext)
|
||||
}
|
56
frontend/src/context/auth-context.jsx
Normal file
56
frontend/src/context/auth-context.jsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { STORE_KEY } from '@utils/enum'
|
||||
import { Helpers } from '@utils/helper'
|
||||
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
const AuthContext = createContext()
|
||||
|
||||
const DEFAULT_AUTH = {
|
||||
isLogged: false,
|
||||
userInfo: null,
|
||||
}
|
||||
|
||||
function getInitialAuth() {
|
||||
return Helpers.decrypt(localStorage.getItem(STORE_KEY), DEFAULT_AUTH)
|
||||
}
|
||||
|
||||
function AuthProvider({ children }) {
|
||||
const [auth, setAuth] = useState(getInitialAuth())
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORE_KEY, Helpers.encrypt(auth))
|
||||
}, [auth])
|
||||
|
||||
const setLoggedIn = useCallback(
|
||||
(value) => {
|
||||
setAuth({
|
||||
...auth,
|
||||
isLogged: value,
|
||||
})
|
||||
},
|
||||
[auth],
|
||||
)
|
||||
|
||||
const setUserInfo = useCallback(
|
||||
(value) => {
|
||||
setAuth({
|
||||
...auth,
|
||||
userInfo: value,
|
||||
})
|
||||
},
|
||||
[auth],
|
||||
)
|
||||
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
auth,
|
||||
setAuth,
|
||||
setLoggedIn,
|
||||
setUserInfo,
|
||||
}),
|
||||
[auth, setLoggedIn, setUserInfo],
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={context}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export { AuthContext, AuthProvider }
|
@ -1,35 +1,37 @@
|
||||
import { getLogout, postLogin } from '@api/auth'
|
||||
import { useNavigate } from '@solidjs/router'
|
||||
import { AuthContext } from '@context/auth-context'
|
||||
import { PathConstants } from '@routes/routes'
|
||||
import { LOGIN_KEY } from '@utils/enum'
|
||||
import { Helpers } from '@utils/helper'
|
||||
import { useContext } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export default function useAuth(setAuth) {
|
||||
export function useSignInUp(setAuth) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const clickLogIn = async (username, password) => {
|
||||
const onLogin = async ({ username, password }) => {
|
||||
const resp = await postLogin({ username, password })
|
||||
|
||||
if (resp.status === 200) {
|
||||
const token = resp.data || {}
|
||||
if (token) {
|
||||
const { name, ...rest } = token
|
||||
setAuth({ auth: true, user: { name } })
|
||||
setAuth({ isLogged: true, userInfo: { name } })
|
||||
localStorage.setItem(LOGIN_KEY, Helpers.encrypt(JSON.stringify(rest)))
|
||||
}
|
||||
|
||||
navigate('/', { replace: true })
|
||||
navigate('/')
|
||||
}
|
||||
}
|
||||
|
||||
const clickLogOut = async () => {
|
||||
const onLogout = async () => {
|
||||
await getLogout()
|
||||
Helpers.clearStorage()
|
||||
setAuth({ auth: false, user: null })
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
return {
|
||||
clickLogOut,
|
||||
clickLogIn,
|
||||
setAuth({ isLogged: false, userInfo: null })
|
||||
navigate(PathConstants.LOGIN)
|
||||
}
|
||||
return { onLogin, onLogout }
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext)
|
||||
}
|
||||
|
@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Returns an object containing the language messages and a function to generate
|
||||
* a required field message based on the provided selectLanguage parameter.
|
||||
*
|
||||
* @param {string} selectLanguage - The language code to use for the messages.
|
||||
* @return {Object} An object with two properties:
|
||||
* - language: An object containing the language messages.
|
||||
* - isRequired: A function that takes a field name and returns a required field
|
||||
* message in the selected language.
|
||||
*/
|
||||
export default function useLanguage(selectLanguage = 'vi') {
|
||||
const data = import.meta.glob('@lang/*.json', {
|
||||
import: 'default',
|
||||
eager: true,
|
||||
})
|
||||
|
||||
const imp = {}
|
||||
|
||||
for (const path in data) {
|
||||
const keypath = path.match(/\/[a-zA-Z]+\./)[0].replace(/\/(\w+)\./, '$1')
|
||||
imp[keypath] = data[path]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string representing a required field message in the selected language.
|
||||
*
|
||||
* @param {string} fieldName - The name of the field.
|
||||
* @return {string} The required field message.
|
||||
*/
|
||||
const isRequired = (fieldName) =>
|
||||
imp[selectLanguage].message['IS_REQUIRED'].replace('%s', fieldName)
|
||||
|
||||
return { language: imp[selectLanguage], isRequired }
|
||||
}
|
@ -1,60 +0,0 @@
|
||||
import { createMemo } from 'solid-js'
|
||||
|
||||
export const DOTS = '...'
|
||||
|
||||
const range = (start, end) => {
|
||||
let length = end - start + 1
|
||||
return Array.from({ length }, (_, idx) => idx + start)
|
||||
}
|
||||
|
||||
export const usePagination = ({
|
||||
totalCount,
|
||||
pageSize,
|
||||
currentPage,
|
||||
siblingCount = 1,
|
||||
}) => {
|
||||
const paginationRange = createMemo(() => {
|
||||
const totalPageCount = Math.ceil(totalCount() / pageSize())
|
||||
|
||||
const totalPageNumbers = siblingCount + 5
|
||||
|
||||
if (totalPageNumbers >= totalPageCount) {
|
||||
return range(1, totalPageCount)
|
||||
}
|
||||
|
||||
const leftSiblingIndex = Math.max(currentPage() - siblingCount, 1)
|
||||
const rightSiblingIndex = Math.min(
|
||||
currentPage() + siblingCount,
|
||||
totalPageCount,
|
||||
)
|
||||
|
||||
const shouldShowLeftDots = leftSiblingIndex > 2
|
||||
const shouldShowRightDots = rightSiblingIndex < totalPageCount - 2
|
||||
|
||||
const firstPageIndex = 1
|
||||
const lastPageIndex = totalPageCount
|
||||
|
||||
if (!shouldShowLeftDots && shouldShowRightDots) {
|
||||
let leftItemCount = 3 + 2 * siblingCount
|
||||
let leftRange = range(1, leftItemCount)
|
||||
|
||||
return [...leftRange, DOTS, totalPageCount]
|
||||
}
|
||||
|
||||
if (shouldShowLeftDots && !shouldShowRightDots) {
|
||||
let rightItemCount = 3 + 2 * siblingCount
|
||||
let rightRange = range(
|
||||
totalPageCount - rightItemCount + 1,
|
||||
totalPageCount,
|
||||
)
|
||||
return [firstPageIndex, DOTS, ...rightRange]
|
||||
}
|
||||
|
||||
if (shouldShowLeftDots && shouldShowRightDots) {
|
||||
let middleRange = range(leftSiblingIndex, rightSiblingIndex)
|
||||
return [firstPageIndex, DOTS, ...middleRange, DOTS, lastPageIndex]
|
||||
}
|
||||
})
|
||||
|
||||
return paginationRange
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
import Notify from '@components/Notify'
|
||||
import toast from 'solid-toast'
|
||||
|
||||
export default function useToast() {
|
||||
const notify = {}
|
||||
|
||||
notify.show = ({ status, title, description, closable = false }) => {
|
||||
return toast.custom((t) => (
|
||||
<Notify
|
||||
status={status}
|
||||
title={title}
|
||||
description={description}
|
||||
onClose={closable ? () => toast.dismiss(t.id) : null}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
notify.success = ({ title, description, closable = false }) => {
|
||||
return notify.show({ status: 'success', title, description, closable })
|
||||
}
|
||||
|
||||
notify.error = ({ title, description, closable = false }) => {
|
||||
return notify.show({ status: 'error', title, description, closable })
|
||||
}
|
||||
|
||||
notify.info = ({ title, description, closable = false }) => {
|
||||
return notify.show({ status: 'info', title, description, closable })
|
||||
}
|
||||
|
||||
return notify
|
||||
}
|
23
frontend/src/i18n.js
Normal file
23
frontend/src/i18n.js
Normal file
@ -0,0 +1,23 @@
|
||||
import i18n from 'i18next'
|
||||
import i18nBackend from 'i18next-http-backend'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
i18n
|
||||
.use(i18nBackend)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
lng: 'vi',
|
||||
fallbackLng: 'en',
|
||||
debug: false,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
// react: {
|
||||
// useSuspense: false
|
||||
// },
|
||||
backend: {
|
||||
loadPath: `${window.location.origin}/i18n/{{lng}}.json`,
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
@ -1,32 +0,0 @@
|
||||
import Layout from '@pages/Layout'
|
||||
import { Route, Router } from '@solidjs/router'
|
||||
import { For, lazy } from 'solid-js'
|
||||
import { render } from 'solid-js/web'
|
||||
import App from './App'
|
||||
import './index.scss'
|
||||
import { ROUTES } from './routes'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
render(
|
||||
() => (
|
||||
<Router root={App}>
|
||||
<Route path="/login" component={lazy(() => import('@pages/Login'))} />
|
||||
<Route path="/" component={Layout}>
|
||||
<For each={ROUTES}>
|
||||
{(route) =>
|
||||
route.show && (
|
||||
<Route
|
||||
path={route.path}
|
||||
component={route.components}
|
||||
matchFilters={route.filter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</For>
|
||||
</Route>
|
||||
<Route path="*" component={lazy(() => import('@pages/NotFound'))} />
|
||||
</Router>
|
||||
),
|
||||
root,
|
||||
)
|
@ -1,20 +1,6 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
}
|
||||
|
||||
@import './assets/css/fu-theme.scss';
|
||||
@import '@mantine/core/styles.css';
|
||||
@import '@mantine/nprogress/styles.css';
|
||||
@import '@mantine/notifications/styles.css';
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
@ -22,8 +8,6 @@
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(0, 0, 0, 1);
|
||||
background-color: #ffffff;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
@ -31,10 +15,35 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
@ -1,64 +0,0 @@
|
||||
{
|
||||
"ui": {
|
||||
"username": "User name",
|
||||
"password": "Password",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"dashboard": "Dashboard",
|
||||
"profile": "Profile",
|
||||
"changeInfo": "Account information",
|
||||
"save": "Save",
|
||||
"clear": "Clear",
|
||||
"house": "Location",
|
||||
"action": "Action",
|
||||
"createNew": "Create new",
|
||||
"warehouse": "Warehouse",
|
||||
"displayName": "Display name",
|
||||
"newPassword": "New password",
|
||||
"confirmNewPassword": "Confirm new password",
|
||||
"newHouse": "Create new location",
|
||||
"houseName": "Location name",
|
||||
"houseIcon": "Icon",
|
||||
"houseAddress": "Address",
|
||||
"areas": "Areas",
|
||||
"areaName": "Area name",
|
||||
"areaDesc": "Description",
|
||||
"addArea": "Add area",
|
||||
"create": "Create",
|
||||
"update": "Update",
|
||||
"delete": "Delete",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"findIconHere": "Find icons here",
|
||||
"empty": "Empty",
|
||||
"error": "Error!",
|
||||
"success": "Success!",
|
||||
"info": "Info",
|
||||
"loading": "Loading...",
|
||||
"showing": "Showing"
|
||||
},
|
||||
"table": {
|
||||
"columnName": {
|
||||
"no": "No.",
|
||||
"name": "Name",
|
||||
"icon": "Icon",
|
||||
"address": "Address",
|
||||
"action": "Action"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"CREATE_USER_SUCCESS": "Create account successfully!",
|
||||
"CREATED_USER": "Username is already in use!",
|
||||
"LOGIN_WRONG": "Wrong username or password.",
|
||||
"USER_LOCK": "Your account is locked.",
|
||||
"IS_REQUIRED": "%s is required.",
|
||||
"PASSWORD_MUSTMATCH": "Password must match.",
|
||||
"UPDATE_SUCCESS": "Update successfully!",
|
||||
"UPDATE_PROFILE_SUCCESS": "Update profile successfully!",
|
||||
"UPDATE_FAIL": "Update failed!",
|
||||
"API_CALL_FAIL": "API call failed!",
|
||||
"CREATE_HOUSE_FAIL": "Create new location failed!",
|
||||
"CREATE_HOUSE_SUCCESS": "Create new location successfully!",
|
||||
"CONFIRM_DELETE": "Are you sure you want to delete this item?"
|
||||
}
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
{
|
||||
"ui": {
|
||||
"username": "Tên người dùng",
|
||||
"password": "Mật khẩu",
|
||||
"login": "Đăng Nhập",
|
||||
"logout": "Đăng xuất",
|
||||
"dashboard": "Bảng điều khiển",
|
||||
"profile": "Hồ sơ",
|
||||
"changeInfo": "Thông tin tài khoản",
|
||||
"save": "Lưu",
|
||||
"clear": "Xóa",
|
||||
"house": "Địa điểm",
|
||||
"action": "Thao Tác",
|
||||
"createNew": "Tạo mới",
|
||||
"warehouse": "Khu vực",
|
||||
"displayName": "Tên hiển thị",
|
||||
"newPassword": "Mật khẩu mới",
|
||||
"confirmNewPassword": "Nhập lại mật khẩu",
|
||||
"newHouse": "Tạo địa điểm mới",
|
||||
"editHouse": "Sửa địa điểm",
|
||||
"houseName": "Tên địa điểm",
|
||||
"houseIcon": "Ký tự",
|
||||
"houseAddress": "Địa chỉ",
|
||||
"areas": "Khu vực",
|
||||
"areaName": "Tên khu vực",
|
||||
"areaDesc": "Mô tả",
|
||||
"addArea": "Thêm khu vực",
|
||||
"editArea": "Sửa khu vực",
|
||||
"create": "Tạo",
|
||||
"update": "Cập nhật",
|
||||
"delete": "Xóa",
|
||||
"confirm": "Xác nhận",
|
||||
"cancel": "Huỷ",
|
||||
"findIconHere": "Tìm ở đây",
|
||||
"empty": "Trống",
|
||||
"error": "lỗi!",
|
||||
"success": "Thành công!",
|
||||
"info": "Thông tin",
|
||||
"loading": "Đang tải...",
|
||||
"showing": "Hiển thị"
|
||||
},
|
||||
"table": {
|
||||
"columnName": {
|
||||
"no": "STT",
|
||||
"name": "Tên",
|
||||
"icon": "Ký tự",
|
||||
"address": "Địa chỉ",
|
||||
"action": "Thao Tác"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"CREATE_USER_SUCCESS": "Tạo tài khoản thành công!",
|
||||
"CREATED_USER": "Tên tài khoản đã có người sử dụng!",
|
||||
"LOGIN_WRONG": "Bạn nhập sai tên người dùng hoặc mật khẩu.",
|
||||
"USER_LOCK": "Tài khoản bạn hiện đang bị khóa.",
|
||||
"IS_REQUIRED": "%s là bắt buộc.",
|
||||
"PASSWORD_MUSTMATCH": "Cần nhập trùng với mật khẩu.",
|
||||
"UPDATE_SUCCESS": "Cập nhật thành công!",
|
||||
"UPDATE_PROFILE_SUCCESS": "Cập nhật hồ sơ thành công!",
|
||||
"UPDATE_FAIL": "Cập nhật thất bại!",
|
||||
"API_CALL_FAIL": "Call API có vẫn đề!",
|
||||
"CREATE_HOUSE_FAIL": "Tạo địa điểm mới thất bại!",
|
||||
"CREATE_HOUSE_SUCCESS": "Tạo địa điểm mới thành công!",
|
||||
"CONFIRM_DELETE": "Bạn có chắc là muốn xóa mục này?",
|
||||
"CONFIRM_DELETE_NOTE": "Chú Ý ‼: Một khi đã XÓA thì không thể nào khôi phục lại được.",
|
||||
"MIN_THREE_CHAR": "Ít nhất phải có 3 ký tự"
|
||||
}
|
||||
}
|
13
frontend/src/main.jsx
Normal file
13
frontend/src/main.jsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
|
||||
import './i18n'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
@ -1,11 +1,13 @@
|
||||
import { NAV_ITEM } from '@components/Navbar'
|
||||
import { useSiteContext } from '@context/SiteContext'
|
||||
import { useNavigate } from '@solidjs/router'
|
||||
import { Helpers } from '@utils/helper'
|
||||
import { createEffect } from 'solid-js'
|
||||
import { useAuth } from '@hooks/useAuth'
|
||||
import { NAV_ITEM } from '@routes/nav-route'
|
||||
import { PathConstants } from '@routes/routes'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
function getFirstItem(array) {
|
||||
const first = array.filter((item) => item.show)[0]
|
||||
|
||||
if (first.children) {
|
||||
return getFirstItem(first.children)
|
||||
}
|
||||
@ -13,20 +15,16 @@ function getFirstItem(array) {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { store } = useSiteContext()
|
||||
const { t } = useTranslation()
|
||||
const { auth } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
createEffect(() => {
|
||||
if (store?.userInfo?.isAdmin) {
|
||||
const first = getFirstItem(NAV_ITEM(store?.userInfo?.isAdmin))
|
||||
navigate(
|
||||
first
|
||||
? Helpers.getRoutePath(first.pathName)
|
||||
: Helpers.getRoutePath('profile'),
|
||||
{ replace: true },
|
||||
)
|
||||
useEffect(() => {
|
||||
if (auth?.userInfo?.isAdmin) {
|
||||
const first = getFirstItem(NAV_ITEM(t, auth?.userInfo?.isAdmin))
|
||||
navigate(first ? first.pathName : PathConstants.PROFILE)
|
||||
}
|
||||
})
|
||||
}, [auth.userInfo.isAdmin])
|
||||
|
||||
return <></>
|
||||
}
|
||||
|
281
frontend/src/pages/House.jsx
Normal file
281
frontend/src/pages/House.jsx
Normal file
@ -0,0 +1,281 @@
|
||||
import { useHouse } from '@api/house'
|
||||
import Datatable from '@components/Datatable'
|
||||
import GridList from '@components/GridList'
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
rem,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Skeleton,
|
||||
Text,
|
||||
TypographyStylesProvider,
|
||||
VisuallyHidden,
|
||||
} from '@mantine/core'
|
||||
import { modals } from '@mantine/modals'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { PathConstants } from '@routes/routes'
|
||||
import * as icons from '@tabler/icons-react'
|
||||
import {
|
||||
IconCircleCheck,
|
||||
IconCircleXFilled,
|
||||
IconGridDots,
|
||||
IconLayoutList,
|
||||
IconMapPin,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react'
|
||||
import { VIEWDATA } from '@utils/enum'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
|
||||
const PAGE_SIZE = [10, 50, 100]
|
||||
|
||||
const getPaginationText = (total, size, page) => {
|
||||
const start = (page - 1) * size + 1
|
||||
const end = Math.min(start + size - 1, total)
|
||||
|
||||
return `${start} - ${end} / ${total}`
|
||||
}
|
||||
|
||||
export default function House() {
|
||||
const { t } = useTranslation()
|
||||
const [view, setView] = useState(VIEWDATA['list'])
|
||||
const [page, setPage] = useState(1)
|
||||
const navigate = useNavigate()
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE[0])
|
||||
const { houses, isLoading, mutate, trigger } = useHouse(page, pageSize)
|
||||
|
||||
const iconProps = (dimansions = 15) => ({
|
||||
style: {
|
||||
width: rem(dimansions),
|
||||
height: rem(dimansions),
|
||||
display: 'block',
|
||||
},
|
||||
stroke: 1.5,
|
||||
})
|
||||
|
||||
const onClickEdit = (editId) => () => {
|
||||
navigate(PathConstants.EDIT_LOCATION.replace(':id', editId), {
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
const onDelete = async (deleteId) => {
|
||||
console.log('delete', deleteId)
|
||||
try {
|
||||
const rs = await trigger(deleteId)
|
||||
if (rs.status === 200) {
|
||||
notifications.show({
|
||||
color: 'green.5',
|
||||
icon: <IconCircleCheck />,
|
||||
withCloseButton: true,
|
||||
title: t('ui_success'),
|
||||
message: t('message_success_delete'),
|
||||
})
|
||||
mutate()
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
color: 'red.5',
|
||||
icon: <IconCircleXFilled />,
|
||||
withCloseButton: true,
|
||||
title: t('ui_error'),
|
||||
message: JSON.stringify(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onClickDelete = (deleteId) => () => {
|
||||
modals.openConfirmModal({
|
||||
title: t('message_confirm_delete'),
|
||||
centered: true,
|
||||
children: (
|
||||
<TypographyStylesProvider fz={'sm'}>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('message_confirm_delete_note'),
|
||||
}}
|
||||
/>
|
||||
</TypographyStylesProvider>
|
||||
),
|
||||
labels: { confirm: t('ui_delete'), cancel: t('ui_cancel') },
|
||||
confirmProps: { color: 'red' },
|
||||
onConfirm: () => onDelete(deleteId),
|
||||
})
|
||||
}
|
||||
|
||||
const columnsData = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('table_col_icon'),
|
||||
width: '10%',
|
||||
field: 'icon',
|
||||
render: ({ icon }, view = false) => {
|
||||
const Icon = icons[icon]
|
||||
return Icon ? <Icon {...iconProps(view ? 50 : 30)} /> : null
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('table_col_name'),
|
||||
width: '35%',
|
||||
field: 'name',
|
||||
render: ({ name }) => <Text>{name}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('table_col_address'),
|
||||
width: '35%',
|
||||
field: 'address',
|
||||
render: ({ address }) => <Text>{address}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('table_col_action'),
|
||||
width: '20%',
|
||||
field: 'action',
|
||||
render: ({ id }, view = false) => {
|
||||
return (
|
||||
<Group justify={view ? 'flex-end' : false}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={onClickEdit(id)}
|
||||
>
|
||||
<IconPencil size={20} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={onClickDelete(id)}
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box>
|
||||
<Divider
|
||||
label={
|
||||
<>
|
||||
<IconMapPin size={20} color="black" />
|
||||
<Text ml="xs" c="black">
|
||||
{t('ui_house')}
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
mb="md"
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<Skeleton height={33} width={100} radius="lg" />
|
||||
<Group justify="flex-end">
|
||||
<Skeleton height={33} width={200} radius="lg" />
|
||||
</Group>
|
||||
</Group>
|
||||
<Box mt="sm">
|
||||
<Skeleton height={500} radius="lg" />
|
||||
</Box>
|
||||
<Group justify="space-between" mt="md">
|
||||
<Group>
|
||||
<Skeleton height={33} width={200} radius="lg" />
|
||||
</Group>
|
||||
<Skeleton height={33} width={350} radius="lg" />
|
||||
</Group>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Divider
|
||||
label={
|
||||
<>
|
||||
<IconMapPin size={20} color="black" />
|
||||
<Text ml="xs" c="black">
|
||||
{t('ui_house')}
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
mb="md"
|
||||
/>
|
||||
<Paper shadow="md" p="md" radius="md">
|
||||
<Group justify="space-between">
|
||||
<SegmentedControl
|
||||
value={view}
|
||||
onChange={setView}
|
||||
data={[
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
<IconLayoutList {...iconProps()} />
|
||||
<VisuallyHidden>List</VisuallyHidden>
|
||||
</>
|
||||
),
|
||||
value: VIEWDATA['list'],
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
<IconGridDots {...iconProps()} />
|
||||
<VisuallyHidden>Grid</VisuallyHidden>
|
||||
</>
|
||||
),
|
||||
value: VIEWDATA['grid'],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
component={Link}
|
||||
to={PathConstants.CREATE_LOCATION}
|
||||
color="green"
|
||||
>
|
||||
{t('ui_create_new')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Box mt="sm">
|
||||
{view === VIEWDATA['list'] ? (
|
||||
<Datatable columns={columnsData} data={houses?.list} />
|
||||
) : (
|
||||
<GridList columns={columnsData} data={houses?.list} />
|
||||
)}
|
||||
</Box>
|
||||
<Group justify="space-between" mt="md">
|
||||
<Group>
|
||||
<Text inline>
|
||||
{t('ui_showing')}:{' '}
|
||||
{getPaginationText(houses?.total, pageSize, page)}
|
||||
</Text>
|
||||
<Select
|
||||
checkIconPosition="right"
|
||||
data={PAGE_SIZE.map((size) => size.toString())}
|
||||
value={pageSize.toString()}
|
||||
onChange={setPageSize}
|
||||
size="xs"
|
||||
allowDeselect={false}
|
||||
w={70}
|
||||
/>
|
||||
</Group>
|
||||
<Pagination
|
||||
total={Math.ceil(houses?.total / pageSize)}
|
||||
siblings={3}
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
@ -1,188 +0,0 @@
|
||||
import ViewSwitch, { VIEWDATA } from '@components/ViewSwitch'
|
||||
import useLanguage from '@hooks/useLanguage'
|
||||
import * as icons from '@tabler/icons-solidjs'
|
||||
import {
|
||||
For,
|
||||
createComponent,
|
||||
createEffect,
|
||||
createResource,
|
||||
createSignal,
|
||||
} from 'solid-js'
|
||||
|
||||
import { getAllHouse } from '@api/house'
|
||||
import ConfirmPopup from '@components/common/ConfirmPopup'
|
||||
import Pagination from '@components/common/Pagination'
|
||||
import { A, useNavigate } from '@solidjs/router'
|
||||
import {
|
||||
IconHome,
|
||||
IconPencil,
|
||||
IconSquareRoundedPlus,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { Helpers } from '@utils/helper'
|
||||
import { Dynamic } from 'solid-js/web'
|
||||
import './house.scss'
|
||||
|
||||
const PAGE_SIZE = [10, 50, 100]
|
||||
|
||||
const getPaginationText = (total, size, page) => {
|
||||
const start = (page - 1) * size + 1
|
||||
const end = Math.min(start + size - 1, total)
|
||||
|
||||
return `${start} - ${end} / ${total}`
|
||||
}
|
||||
|
||||
const fetchHouses = async ({ page, pageSize }) => {
|
||||
const response = await getAllHouse({ page, pageSize })
|
||||
return response
|
||||
}
|
||||
|
||||
export default function House() {
|
||||
const { language } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const [pageSize, setPageSize] = createSignal(PAGE_SIZE[0])
|
||||
const [currentPage, setCurrentPage] = createSignal(1)
|
||||
const [view, setView] = createSignal(VIEWDATA['list'])
|
||||
const [record, setRecord] = createSignal(null)
|
||||
const [totalRecord, setTotalRecord] = createSignal(0)
|
||||
const [houses] = createResource(
|
||||
() => ({ page: currentPage(), pageSize: pageSize() }),
|
||||
fetchHouses,
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (houses()) {
|
||||
setRecord(houses()?.data?.list)
|
||||
setTotalRecord(houses()?.data?.total)
|
||||
}
|
||||
})
|
||||
|
||||
const onClickEdit = (editId) => {
|
||||
navigate(Helpers.getRoutePath('edit-location').replace(':id', editId), {
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
const onConfirmDelete = (deleteId) => {
|
||||
console.log(deleteId)
|
||||
}
|
||||
|
||||
const onClickDelete = (deleteId) => {
|
||||
createComponent(ConfirmPopup, {
|
||||
title: language?.message['CONFIRM_DELETE'],
|
||||
children: language?.message['CONFIRM_DELETE_NOTE'],
|
||||
deleteId,
|
||||
onConfirm: onConfirmDelete,
|
||||
})
|
||||
}
|
||||
|
||||
const onSetPageSize = (pageSize) => {
|
||||
setPageSize(pageSize)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="house">
|
||||
<div class="flex items-center gap-2 mb-5 text-xl">
|
||||
<span class="text-secondary">
|
||||
<IconHome size={30} />
|
||||
</span>
|
||||
{language.ui.house}
|
||||
</div>
|
||||
<div class="page-topbar flex justify-between mb-4">
|
||||
<ViewSwitch switchView={setView} />
|
||||
<A
|
||||
href="/location/create"
|
||||
class="btn btn-success text-white hover:text-white btn-sm"
|
||||
>
|
||||
<IconSquareRoundedPlus size={15} />
|
||||
{language.ui.createNew}
|
||||
</A>
|
||||
</div>
|
||||
<div
|
||||
class="view-layout scroll-shadow-horizontal no-scrollbar"
|
||||
classList={{
|
||||
'view-list': view() === VIEWDATA['list'],
|
||||
'view-grid': view() === VIEWDATA['grid'],
|
||||
}}
|
||||
>
|
||||
<div class="view-table">
|
||||
<div class="row view-head">
|
||||
<div class="col w-1/12">{language.table.columnName.no}</div>
|
||||
<div class="col w-1/12">{language.table.columnName.icon}</div>
|
||||
<div class="col w-4/12">{language.table.columnName.name}</div>
|
||||
<div class="col w-4/12">{language.table.columnName.address}</div>
|
||||
<div class="col w-2/12">{language.table.columnName.action}</div>
|
||||
</div>
|
||||
<For each={record()}>
|
||||
{(item, idx) => (
|
||||
<div class="row view-item">
|
||||
<div class="col hide w-1/12">
|
||||
{pageSize() * currentPage() - (pageSize() - idx() - 1)}
|
||||
</div>
|
||||
<div class="col symbol w-1/12">
|
||||
<Dynamic component={icons[item?.icon]} class="w-7 h-7" />
|
||||
</div>
|
||||
<div class="col w-4/12">{item?.name}</div>
|
||||
<div class="col w-4/12">{item?.address}</div>
|
||||
<div class="col actionbar w-2/12">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm px-1 text-blue-500"
|
||||
onClick={[onClickEdit, item?.id]}
|
||||
>
|
||||
<IconPencil size={20} />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm px-1 text-red-500"
|
||||
onClick={[onClickDelete, item?.id]}
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-botbar flex max-xs:flex-col justify-between items-center gap-2 mt-5">
|
||||
<div class="bar-left flex gap-2 justify-start items-center">
|
||||
<div class="py-2 px-3 border rounded-lg border-gray-300 leading-none">
|
||||
<span>
|
||||
{language.ui.showing}:{' '}
|
||||
{getPaginationText(totalRecord(), pageSize(), currentPage())}
|
||||
</span>
|
||||
</div>
|
||||
<div class="dropdown dropdown-top dropdown-end">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="btn btn-sm border border-gray-300"
|
||||
>
|
||||
{pageSize()}
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-32 p-2 shadow mb-2"
|
||||
>
|
||||
<For each={PAGE_SIZE}>
|
||||
{(pageSize) => (
|
||||
<li>
|
||||
<a onClick={[onSetPageSize, pageSize]}>{pageSize}</a>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bar-right">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalCount={totalRecord}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
.view-layout {
|
||||
&.view-list {
|
||||
@apply rounded-2xl border shadow-md overflow-auto;
|
||||
|
||||
& .view-table {
|
||||
@apply flex flex-col min-w-[800px];
|
||||
}
|
||||
|
||||
& .row {
|
||||
@apply flex justify-between items-center flex-nowrap px-3 border-b border-gray-200 h-14;
|
||||
|
||||
&.view-head {
|
||||
@apply bg-base-200 font-bold;
|
||||
}
|
||||
|
||||
& > .actionbar {
|
||||
@apply flex gap-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.view-grid {
|
||||
@apply mt-5;
|
||||
|
||||
& .view-table {
|
||||
@apply grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5;
|
||||
}
|
||||
|
||||
& .row {
|
||||
&.view-item {
|
||||
@apply card bg-base-100 shadow-md border border-gray-200 relative
|
||||
grid grid-rows-2 grid-flow-col p-2 pr-4 gap-1 auto-cols-max overflow-hidden;
|
||||
|
||||
& .col {
|
||||
@apply w-auto;
|
||||
}
|
||||
|
||||
& > .hide {
|
||||
@apply hidden;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
& .col.symbol {
|
||||
@apply row-span-2 border-r border-gray-300 bg-white flex items-center pr-2 mr-2;
|
||||
|
||||
& > svg {
|
||||
@apply w-14 h-14;
|
||||
}
|
||||
}
|
||||
|
||||
& > .actionbar {
|
||||
@apply absolute flex flex-col justify-center px-3 w-auto h-full border-l rounded-xl bg-white z-10 transition-all;
|
||||
right: -45px;
|
||||
box-shadow: -1px -1px 10px 0px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
&:hover > .actionbar {
|
||||
@apply right-0;
|
||||
}
|
||||
}
|
||||
|
||||
&.view-head {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { default } from './House'
|
279
frontend/src/pages/HouseCreate.jsx
Normal file
279
frontend/src/pages/HouseCreate.jsx
Normal file
@ -0,0 +1,279 @@
|
||||
import { useHouseDetail } from '@api/house'
|
||||
import AreaInput from '@components/AreaInput'
|
||||
import { errorTip, labelWithIcon } from '@components/Common'
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
Skeleton,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core'
|
||||
import { useForm, yupResolver } from '@mantine/form'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { PathConstants } from '@routes/routes'
|
||||
import {
|
||||
IconAddressBook,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconCircleXFilled,
|
||||
IconIcons,
|
||||
IconMapPin,
|
||||
icons,
|
||||
IconTagFilled,
|
||||
IconVector,
|
||||
} from '@tabler/icons-react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, NavLink, useNavigate, useParams } from 'react-router-dom'
|
||||
import * as yup from 'yup'
|
||||
|
||||
const houseSchema = (t) =>
|
||||
yup.object({
|
||||
icon: yup
|
||||
.string()
|
||||
.min(3, t('message_min_three_char'))
|
||||
.required(t('message_is_required', { field: t('ui_house_icon') })),
|
||||
name: yup
|
||||
.string()
|
||||
.min(3, t('message_min_three_char'))
|
||||
.required(t('message_is_required', { field: t('ui_house_name') })),
|
||||
address: yup
|
||||
.string()
|
||||
.min(3, t('message_min_three_char'))
|
||||
.required(t('message_is_required', { field: t('ui_house_address') })),
|
||||
areas: yup
|
||||
.array()
|
||||
.min(1, t('message_is_required', { field: t('ui_areas') }))
|
||||
.required(t('message_is_required', { field: t('ui_areas') })),
|
||||
})
|
||||
|
||||
function IconDynamic({ icon }) {
|
||||
const Icon = icons[icon]
|
||||
|
||||
return Icon ? <Icon size={15} /> : null
|
||||
}
|
||||
|
||||
export default function HouseCreate() {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { house, isLoading, trigger } = useHouseDetail(id)
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
icon: '',
|
||||
name: '',
|
||||
address: '',
|
||||
areas: [],
|
||||
},
|
||||
validate: yupResolver(houseSchema(t)),
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
error: errorTip(payload.inputProps.error),
|
||||
}),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (id && house) {
|
||||
form.setValues(house)
|
||||
}
|
||||
}, [house])
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const result = {
|
||||
...values,
|
||||
areas: values.areas.map((item) =>
|
||||
item.isCreate ? { name: item.name, desc: item.desc } : { ...item },
|
||||
),
|
||||
}
|
||||
try {
|
||||
const data = await trigger(result)
|
||||
|
||||
if (data.status === 200) {
|
||||
notifications.show({
|
||||
color: 'green.5',
|
||||
icon: <IconCircleCheck />,
|
||||
title: t('ui_success'),
|
||||
message: t('message_action_house_success', {
|
||||
action: t(id ? 'ui_update' : 'ui_create'),
|
||||
}),
|
||||
})
|
||||
navigate(PathConstants.LOCATION)
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
color: 'red.5',
|
||||
icon: <IconCircleXFilled />,
|
||||
withCloseButton: true,
|
||||
title: t('ui_error'),
|
||||
message: error
|
||||
? error
|
||||
: t('message_action_house_fail', {
|
||||
action: t(id ? 'ui_update' : 'ui_create'),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box>
|
||||
<Breadcrumbs mb="md">
|
||||
<Anchor
|
||||
component={NavLink}
|
||||
fz="xs"
|
||||
to={PathConstants.LOCATION}
|
||||
underline="hover"
|
||||
c="blue"
|
||||
>
|
||||
{t('ui_house')}
|
||||
</Anchor>
|
||||
<Anchor component="span" fz="xs" underline="never" c="blue">
|
||||
{t('ui_new_house')}
|
||||
</Anchor>
|
||||
</Breadcrumbs>
|
||||
<Divider
|
||||
label={
|
||||
<>
|
||||
<IconMapPin size={20} color="black" />
|
||||
<Text ml="xs" c="black">
|
||||
{t('ui_new_house')}
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
mb="md"
|
||||
/>
|
||||
<Paper shadow="md" p="md" radius="md">
|
||||
<Grid gutter="md">
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<Skeleton height={33} radius="lg" />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<Skeleton height={33} radius="lg" />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Skeleton height={33} radius="lg" />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Skeleton height={300} radius="lg" />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Group position="right">
|
||||
<Skeleton height={33} width={70} radius="lg" />
|
||||
<Skeleton height={33} width={70} radius="lg" />
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Breadcrumbs mb="md">
|
||||
<Anchor
|
||||
component={NavLink}
|
||||
fz="xs"
|
||||
to={PathConstants.LOCATION}
|
||||
underline="hover"
|
||||
c="blue"
|
||||
>
|
||||
{t('ui_house')}
|
||||
</Anchor>
|
||||
<Anchor component="span" fz="xs" underline="never" c="blue">
|
||||
{t('ui_new_house')}
|
||||
</Anchor>
|
||||
</Breadcrumbs>
|
||||
<Divider
|
||||
label={
|
||||
<>
|
||||
<IconMapPin size={20} color="black" />
|
||||
<Text ml="xs" c="black">
|
||||
{t('ui_new_house')}
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
mb="md"
|
||||
/>
|
||||
<Paper shadow="md" p="md" radius="md">
|
||||
<form autoComplete="off" onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Grid gutter="md">
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<Select
|
||||
label={labelWithIcon(t('ui_house_icon'), IconIcons)}
|
||||
placeholder={t('ui_house_icon')}
|
||||
searchable
|
||||
limit={7}
|
||||
data={Object.entries(icons).map(([key, value]) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
icon: value,
|
||||
}))}
|
||||
renderOption={({ option, checked }) => {
|
||||
const Icon = option.icon
|
||||
return (
|
||||
<Group flex="1" gap="xs">
|
||||
<Icon size={20} />
|
||||
{option.label}
|
||||
{checked && (
|
||||
<IconCheck
|
||||
style={{ marginInlineStart: 'auto' }}
|
||||
size={15}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
}}
|
||||
nothingFoundMessage={t('ui_empty')}
|
||||
leftSection={<IconDynamic icon={form.values.icon} />}
|
||||
{...form.getInputProps('icon')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
<TextInput
|
||||
label={labelWithIcon(t('ui_house_name'), IconTagFilled)}
|
||||
placeholder={t('ui_house_name')}
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<TextInput
|
||||
label={labelWithIcon(t('ui_house_address'), IconAddressBook)}
|
||||
placeholder={t('ui_house_address')}
|
||||
{...form.getInputProps('address')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<AreaInput
|
||||
label={labelWithIcon(t('ui_areas'), IconVector)}
|
||||
{...form.getInputProps('areas')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Group position="right">
|
||||
<Button type="submit">
|
||||
{t(id ? 'ui_update' : 'ui_create')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
component={Link}
|
||||
to={PathConstants.LOCATION}
|
||||
color="red"
|
||||
>
|
||||
{t('ui_cancel')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</form>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
@ -1,195 +0,0 @@
|
||||
import { getHouseDetail, postCreateHouse, putUpdateHouse } from '@api/house'
|
||||
import AreaAdd from '@components/AreaAdd'
|
||||
import TextInput from '@components/common/TextInput'
|
||||
import { createForm } from '@felte/solid'
|
||||
import { validator } from '@felte/validator-yup'
|
||||
import useLanguage from '@hooks/useLanguage'
|
||||
import useToast from '@hooks/useToast'
|
||||
import { A, useNavigate, useParams } from '@solidjs/router'
|
||||
import {
|
||||
IconAddressBook,
|
||||
IconIcons,
|
||||
IconLocationBolt,
|
||||
IconMapPinPlus,
|
||||
IconTag,
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { Helpers } from '@utils/helper'
|
||||
import { createEffect, createResource, Show } from 'solid-js'
|
||||
import * as yup from 'yup'
|
||||
|
||||
const houseSchema = (language, isRequired) =>
|
||||
yup.object({
|
||||
icon: yup
|
||||
.string()
|
||||
.min(3, language.message['MIN_THREE_CHAR'])
|
||||
.required(isRequired(language.ui.houseIcon)),
|
||||
name: yup
|
||||
.string()
|
||||
.min(3, language.message['MIN_THREE_CHAR'])
|
||||
.required(isRequired(language.ui.houseName)),
|
||||
address: yup
|
||||
.string()
|
||||
.min(3, language.message['MIN_THREE_CHAR'])
|
||||
.required(isRequired(language.ui.houseAddress)),
|
||||
areas: yup
|
||||
.array()
|
||||
.min(1, isRequired(language.ui.areas))
|
||||
.required(isRequired(language.ui.areas)),
|
||||
})
|
||||
|
||||
const fetchHouses = async (id) => {
|
||||
if (id) {
|
||||
const response = await getHouseDetail(id)
|
||||
if (response.status === 200) {
|
||||
return response.data
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default function HouseCreate() {
|
||||
const { language, isRequired } = useLanguage()
|
||||
const notify = useToast()
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams()
|
||||
const [house] = createResource(id, fetchHouses)
|
||||
const { form, data, setData, errors } = createForm({
|
||||
extend: [validator({ schema: houseSchema(language, isRequired) })],
|
||||
onSubmit: async (values) => {
|
||||
const call = id ? putUpdateHouse : postCreateHouse
|
||||
const result = {
|
||||
...values,
|
||||
areas: values.areas.map((item) =>
|
||||
id
|
||||
? { ...item }
|
||||
: {
|
||||
name: item.name,
|
||||
desc: item.desc,
|
||||
},
|
||||
),
|
||||
}
|
||||
console.log(values.areas)
|
||||
const resp = await call(result)
|
||||
return resp
|
||||
},
|
||||
onSuccess: (resp) => {
|
||||
if (resp.status === 200) {
|
||||
notify.success({
|
||||
title: language.ui.success,
|
||||
description:
|
||||
language.message[resp.data] ||
|
||||
language.message['CREATE_HOUSE_SUCCESS'],
|
||||
})
|
||||
// navigate(Helpers.getRoutePath('location'), { replace: true })
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
notify.error({
|
||||
title: language.ui.error,
|
||||
description:
|
||||
language.message[error.data] || language.message['API_CALL_FAIL'],
|
||||
closable: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (id && house()) {
|
||||
setData(house())
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="house-create">
|
||||
<div class="text-sm breadcrumbs mb-2">
|
||||
<ul>
|
||||
<li>
|
||||
<A href={Helpers.getRoutePath('location')}>{language.ui.house}</A>
|
||||
</li>
|
||||
<li>{id ? language.ui.editHouse : language.ui.newHouse}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-5 text-xl">
|
||||
<span class="text-secondary">
|
||||
{id ? <IconLocationBolt size={30} /> : <IconMapPinPlus size={30} />}
|
||||
</span>
|
||||
{id ? language.ui.editHouse : language.ui.newHouse}
|
||||
</div>
|
||||
<div class="card w-full bg-base-100 shadow-lg border border-gray-200">
|
||||
<div class="card-body">
|
||||
<form autoComplete="off" use:form>
|
||||
<Show when={id}>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
</Show>
|
||||
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
||||
<div class="col-auto">
|
||||
<TextInput
|
||||
icon={IconIcons}
|
||||
name="icon"
|
||||
label={language.ui.houseIcon}
|
||||
labelClass="md:w-40"
|
||||
placeholder={language.ui.houseIcon}
|
||||
value={data('icon')}
|
||||
error={errors('icon')}
|
||||
>
|
||||
<div class="label">
|
||||
<a
|
||||
class="label-text link link-info"
|
||||
href="https://tabler.io/icons"
|
||||
target="_blank"
|
||||
>
|
||||
{language.ui.findIconHere}
|
||||
</a>
|
||||
</div>
|
||||
</TextInput>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<TextInput
|
||||
icon={IconTag}
|
||||
name="name"
|
||||
label={language.ui.houseName}
|
||||
labelClass="md:w-40"
|
||||
placeholder={language.ui.houseName}
|
||||
value={data('name')}
|
||||
error={errors('name')}
|
||||
/>
|
||||
</div>
|
||||
<div class="col-auto lg:col-span-2">
|
||||
<TextInput
|
||||
icon={IconAddressBook}
|
||||
name="address"
|
||||
label={language.ui.houseAddress}
|
||||
labelClass="md:w-40"
|
||||
placeholder={language.ui.houseAddress}
|
||||
value={data('address')}
|
||||
error={errors('address')}
|
||||
/>
|
||||
</div>
|
||||
<div class="col-auto lg:col-span-2">
|
||||
<AreaAdd
|
||||
name="areas"
|
||||
value={data('areas')}
|
||||
setData={setData}
|
||||
error={
|
||||
errors('areas') &&
|
||||
Helpers.clearArrayWithNullObject(errors('areas'))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{id ? language.ui.update : language.ui.create}
|
||||
</button>
|
||||
<A href={Helpers.getRoutePath('location')} class="btn btn-ghost">
|
||||
{language.ui.cancel}
|
||||
</A>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export * from './HouseCreate'
|
||||
export { default } from './HouseCreate'
|
@ -1,31 +1,41 @@
|
||||
import Header from '@components/Header'
|
||||
import Navbar from '@components/Navbar'
|
||||
import { useSiteContext } from '@context/SiteContext'
|
||||
import { useNavigate } from '@solidjs/router'
|
||||
import { onMount } from 'solid-js'
|
||||
import { useAuth } from '@hooks/useAuth'
|
||||
import { AppShell, ScrollArea } from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { PathConstants } from '@routes/routes'
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet, useNavigate } from 'react-router-dom'
|
||||
|
||||
export default function Layout(props) {
|
||||
const { store } = useSiteContext()
|
||||
export default function Layout() {
|
||||
const [opened, { toggle }] = useDisclosure()
|
||||
const { auth } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
onMount(() => {
|
||||
if (!store.auth) {
|
||||
navigate('/login', { replace: true })
|
||||
useEffect(() => {
|
||||
if (!auth.isLogged) {
|
||||
navigate(PathConstants.LOGIN)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div class="layer-top">
|
||||
<Header />
|
||||
<div id="main-page">
|
||||
<div class="drawer lg:drawer-open">
|
||||
<input id="nav-menu" type="checkbox" class="drawer-toggle" />
|
||||
<div class="drawer-content flex flex-col">
|
||||
<main class="main-content p-3 pb-5">{props.children}</main>
|
||||
</div>
|
||||
<AppShell
|
||||
header={{ height: 60 }}
|
||||
navbar={{ width: 300, breakpoint: 'md', collapsed: { mobile: !opened } }}
|
||||
padding="md"
|
||||
bg="#f2fcff"
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Header opened={opened} onClick={toggle} />
|
||||
</AppShell.Header>
|
||||
<AppShell.Navbar>
|
||||
<AppShell.Section component={ScrollArea}>
|
||||
<Navbar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
<AppShell.Main>
|
||||
<Outlet />
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
@ -1,90 +1,130 @@
|
||||
import { useSiteContext } from '@context/SiteContext'
|
||||
import { createForm } from '@felte/solid'
|
||||
import { validator } from '@felte/validator-yup'
|
||||
import useLanguage from '@hooks/useLanguage'
|
||||
import { useNavigate } from '@solidjs/router'
|
||||
import { IconKey, IconUser } from '@tabler/icons-solidjs'
|
||||
import { onMount } from 'solid-js'
|
||||
import * as yup from 'yup'
|
||||
import './login.scss'
|
||||
|
||||
import Logo from '@assets/images/logo.svg'
|
||||
import TextInput from '@components/common/TextInput'
|
||||
import useAuth from '@hooks/useAuth'
|
||||
import useToast from '@hooks/useToast'
|
||||
|
||||
const loginSchema = (language, isRequired) =>
|
||||
yup.object({
|
||||
username: yup.string().required(isRequired(language.ui.username)),
|
||||
password: yup.string().required(isRequired(language.ui.password)),
|
||||
})
|
||||
import { errorTip, labelWithIcon } from '@components/Common'
|
||||
import { useAuth, useSignInUp } from '@hooks/useAuth'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Image,
|
||||
PasswordInput,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core'
|
||||
import { isNotEmpty, useForm } from '@mantine/form'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import {
|
||||
IconCircleCheck,
|
||||
IconCircleXFilled,
|
||||
IconKeyFilled,
|
||||
IconUserFilled,
|
||||
} from '@tabler/icons-react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { s_logo, s_wrapper } from './Login.module.scss'
|
||||
|
||||
export default function Login() {
|
||||
const { store, setAuth } = useSiteContext()
|
||||
const { language, isRequired } = useLanguage()
|
||||
const {
|
||||
auth: { isLogged },
|
||||
setAuth,
|
||||
} = useAuth()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { clickLogIn } = useAuth(setAuth)
|
||||
const notify = useToast()
|
||||
|
||||
const { form, errors } = createForm({
|
||||
extend: [validator({ schema: loginSchema(language, isRequired) })],
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
const { username, password } = values
|
||||
return await clickLogIn(username, password)
|
||||
} catch (error) {
|
||||
notify.error({
|
||||
title: 'Login fail!',
|
||||
description: error?.data
|
||||
? language.message[error.data]
|
||||
: 'Your username or password input is wrong!',
|
||||
closable: true,
|
||||
})
|
||||
}
|
||||
const { onLogin } = useSignInUp(setAuth)
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
validate: {
|
||||
username: isNotEmpty(
|
||||
t('message_is_required', { field: t('ui_username') }),
|
||||
),
|
||||
password: isNotEmpty(
|
||||
t('message_is_required', { field: t('ui_password') }),
|
||||
),
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
error: errorTip(payload.inputProps.error),
|
||||
}),
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (store.auth) {
|
||||
useEffect(() => {
|
||||
if (isLogged) {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmitLogin = async (values) => {
|
||||
try {
|
||||
await onLogin(values)
|
||||
notifications.show({
|
||||
color: 'green.5',
|
||||
icon: <IconCircleCheck />,
|
||||
title: t('message_login_success'),
|
||||
message: t('message_welcom_back'),
|
||||
})
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
color: 'red.5',
|
||||
icon: <IconCircleXFilled />,
|
||||
title: t('message_login_fail'),
|
||||
message: error?.data ? t(error.data) : t('message_login_wrong'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="login-page">
|
||||
<div class="card glass card-compact login-wrap shadow-xl">
|
||||
<div class="h-44">
|
||||
<picture class="logo">
|
||||
<source srcSet={Logo} type="image/png" media="(min-width: 600px)" />
|
||||
<img src={Logo} alt="logo" />
|
||||
</picture>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">{language.ui.login}</h1>
|
||||
<form autoComplete="off" use:form>
|
||||
<TextInput
|
||||
name="username"
|
||||
placeholder={language.ui.username}
|
||||
icon={IconUser}
|
||||
label={language.ui.username}
|
||||
error={errors('username')}
|
||||
/>
|
||||
<TextInput
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={language.ui.password}
|
||||
icon={IconKey}
|
||||
label={language.ui.password}
|
||||
error={errors('password')}
|
||||
/>
|
||||
<div class="card-actions justify-end mt-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{language.ui.login}
|
||||
</button>
|
||||
</div>
|
||||
<Container
|
||||
fluid
|
||||
style={{
|
||||
background: '#fff url(/images/bg-login.jpg) no-repeat fixed center',
|
||||
backgroundSize: 'cover',
|
||||
}}
|
||||
h="100svh"
|
||||
>
|
||||
<Center h="100svh">
|
||||
<Card
|
||||
shadow="xl"
|
||||
radius={20}
|
||||
w="40%"
|
||||
maw={500}
|
||||
miw={320}
|
||||
className={s_wrapper}
|
||||
>
|
||||
<Card.Section>
|
||||
<Image src={Logo} className={s_logo} alt="logo" />
|
||||
</Card.Section>
|
||||
<form onSubmit={form.onSubmit(onSubmitLogin)}>
|
||||
<Box pos="relative" style={{ zIndex: 4 }}>
|
||||
<Title order={1} fz={18} mb="sm" mt="sm">
|
||||
{t('ui_login')}
|
||||
</Title>
|
||||
<TextInput
|
||||
withAsterisk
|
||||
label={labelWithIcon(t('ui_username'), IconUserFilled)}
|
||||
placeholder={t('ui_username')}
|
||||
key={form.key('username')}
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
withAsterisk
|
||||
label={labelWithIcon(t('ui_password'), IconKeyFilled)}
|
||||
placeholder={t('ui_password')}
|
||||
mt="sm"
|
||||
key={form.key('password')}
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button type="submit">{t('ui_login')}</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Center>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
42
frontend/src/pages/Login/Login.module.scss
Normal file
42
frontend/src/pages/Login/Login.module.scss
Normal file
@ -0,0 +1,42 @@
|
||||
.s_wrapper {
|
||||
position: relative;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
border-radius: 15px;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: -120px;
|
||||
left: -285px;
|
||||
background: #10b981;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 520px;
|
||||
height: 520px;
|
||||
border-radius: 15px;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: -40px;
|
||||
left: -130px;
|
||||
background: #ff6600;
|
||||
transform: rotate(20deg);
|
||||
}
|
||||
|
||||
& .s_logo {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
display: block;
|
||||
width: 40%;
|
||||
max-width: 150px;
|
||||
min-width: 100px;
|
||||
margin: 0 auto;
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
@ -1 +1,2 @@
|
||||
export * from './Login'
|
||||
export { default } from './Login'
|
||||
|
@ -1,69 +0,0 @@
|
||||
.login-page {
|
||||
width: 100%;
|
||||
height: 100svh;
|
||||
display: flex;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
background: #fff url('/images/bg-login.jpg') no-repeat fixed center;
|
||||
background-size: cover;
|
||||
place-items: center;
|
||||
|
||||
.login-wrap {
|
||||
width: 40%;
|
||||
max-width: 500px;
|
||||
min-width: 320px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
border-radius: 15px;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: -120px;
|
||||
left: -285px;
|
||||
background: #10b981;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
border-radius: 15px;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: -40px;
|
||||
left: -130px;
|
||||
background: #ff6600;
|
||||
transform: rotate(20deg);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
display: block;
|
||||
width: 40%;
|
||||
max-width: 150px;
|
||||
min-width: 100px;
|
||||
margin: 0 auto;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.25);
|
||||
border-radius: 5px;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
import { A } from '@solidjs/router'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div class="flex items-center justify-center min-h-screen bg-fu-green bg-fixed bg-cover bg-bottom error-bg">
|
||||
<div class="flex flex-col items-center text-white">
|
||||
<div class="relative text-center">
|
||||
<h1 class="relative text-9xl tracking-tighter-less text-shadow font-sans font-bold">
|
||||
<span>4</span>
|
||||
<span>0</span>
|
||||
<span>4</span>
|
||||
</h1>
|
||||
<span class="absolute top-0 -ml-12 font-semibold">Oops!</span>
|
||||
</div>
|
||||
<h5 class="font-semibold">Page not found</h5>
|
||||
<p class="mt-2 mb-6">
|
||||
we are sorry, but the page you requested was not found
|
||||
</p>
|
||||
<A href="/" class="btn btn-secondary btn-sm hover:text-white">
|
||||
Got to Home
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { default } from './NotFound'
|
13
frontend/src/pages/Page404.jsx
Normal file
13
frontend/src/pages/Page404.jsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Text } from '@mantine/core'
|
||||
import { useRouteError } from 'react-router-dom'
|
||||
|
||||
export default function Page404() {
|
||||
let error = useRouteError()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>404</Text>
|
||||
<pre>{error.message || JSON.stringify(error)}</pre>
|
||||
</>
|
||||
)
|
||||
}
|
124
frontend/src/pages/Profile.jsx
Normal file
124
frontend/src/pages/Profile.jsx
Normal file
@ -0,0 +1,124 @@
|
||||
import { putUpdateProfile } from '@api/user'
|
||||
import { errorTip, labelWithIcon } from '@components/Common'
|
||||
import { useAuth } from '@hooks/useAuth'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core'
|
||||
import { useForm, yupResolver } from '@mantine/form'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import {
|
||||
IconCircleCheckFilled,
|
||||
IconKeyFilled,
|
||||
IconUserCircle,
|
||||
IconUserFilled,
|
||||
} from '@tabler/icons-react'
|
||||
import { Helpers } from '@utils/helper'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as yup from 'yup'
|
||||
|
||||
const vschema = (t) =>
|
||||
yup.object({
|
||||
name: yup
|
||||
.string()
|
||||
.required(t('message_is_required', { field: t('ui_display_name') })),
|
||||
password: yup.string().nullable().optional(),
|
||||
confirmPassword: yup.string().when('password', {
|
||||
is: (val) => !!(val && val.length > 0),
|
||||
then: (schema) =>
|
||||
schema.oneOf(
|
||||
[yup.ref('password'), null],
|
||||
t('message_password_mustmatch'),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
export default function Profile() {
|
||||
const { t } = useTranslation()
|
||||
const { auth, setUserInfo } = useAuth()
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
validate: yupResolver(vschema(t)),
|
||||
transformValues: (values) => ({
|
||||
name: values.name,
|
||||
password: values.password,
|
||||
}),
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
error: errorTip(payload.inputProps.error),
|
||||
}),
|
||||
})
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const resp = await putUpdateProfile(Helpers.clearObject(values))
|
||||
|
||||
if (resp.status === 200) {
|
||||
setUserInfo(resp.data)
|
||||
form.reset()
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
color: 'green.5',
|
||||
icon: <IconCircleCheckFilled size={30} />,
|
||||
title: t('ui_success'),
|
||||
message: t('message_update_success'),
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.userInfo) {
|
||||
form.setFieldValue('name', auth?.userInfo?.name)
|
||||
}
|
||||
}, [auth.userInfo])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Divider
|
||||
label={
|
||||
<>
|
||||
<IconUserCircle size={20} color="black" />
|
||||
<Text ml="xs" c="black">
|
||||
{t('ui_profile')}
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
mb="md"
|
||||
/>
|
||||
<Paper shadow="md" p="md" radius="lg">
|
||||
<Text mb="lg">{t('ui_change_info')}</Text>
|
||||
<form onSubmit={form.onSubmit(onSubmit)} autoComplete="off">
|
||||
<TextInput
|
||||
label={labelWithIcon(t('ui_display_name'), IconUserFilled)}
|
||||
placeholder={t('ui_display_name')}
|
||||
mb="md"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={labelWithIcon(t('ui_new_password'), IconKeyFilled)}
|
||||
placeholder={t('ui_new_password')}
|
||||
mb="md"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={labelWithIcon(t('ui_confirm_new_password'), IconKeyFilled)}
|
||||
placeholder={t('ui_confirm_new_password')}
|
||||
{...form.getInputProps('confirmPassword')}
|
||||
/>
|
||||
<Group position="right" mt="md">
|
||||
<Button type="submit">{t('ui_save')}</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
@ -1,114 +0,0 @@
|
||||
import { putUpdateProfile } from '@api/user'
|
||||
import TextInput from '@components/common/TextInput'
|
||||
import { useSiteContext } from '@context/SiteContext'
|
||||
import { createForm } from '@felte/solid'
|
||||
import { validator } from '@felte/validator-yup'
|
||||
import useLanguage from '@hooks/useLanguage'
|
||||
import useToast from '@hooks/useToast'
|
||||
import { IconKey, IconUser, IconUserCircle } from '@tabler/icons-solidjs'
|
||||
import { Helpers } from '@utils/helper'
|
||||
import * as yup from 'yup'
|
||||
|
||||
const profileSchema = (language, isRequired) =>
|
||||
yup.object({
|
||||
name: yup.string().required(isRequired(language.ui.displayName)),
|
||||
password: yup.string().nullable().optional(),
|
||||
'confirm-password': yup.string().when('password', {
|
||||
is: (val) => !!(val && val.length > 0),
|
||||
then: (schema) =>
|
||||
schema.oneOf(
|
||||
[yup.ref('password'), null],
|
||||
language.message['PASSWORD_MUSTMATCH'],
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
export default function Profile() {
|
||||
const {
|
||||
store: { userInfo },
|
||||
setUser,
|
||||
} = useSiteContext()
|
||||
const { language, isRequired } = useLanguage()
|
||||
const notify = useToast()
|
||||
|
||||
const { form, errors, reset } = createForm({
|
||||
extend: [validator({ schema: profileSchema(language, isRequired) })],
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
const { name, password } = values
|
||||
const clearObj = Helpers.clearObject({
|
||||
name: name || null,
|
||||
password: password || null,
|
||||
})
|
||||
const resp = await putUpdateProfile(clearObj)
|
||||
|
||||
if (resp.status === 200) {
|
||||
setUser(resp.data)
|
||||
reset()
|
||||
notify.success({
|
||||
title: language.ui.success,
|
||||
description:
|
||||
language.message[resp.data] ||
|
||||
language.message['CREATE_USER_SUCCESS'],
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
notify.error({
|
||||
title: language.ui.error,
|
||||
description:
|
||||
language.message[error?.data] || language.message['API_CALL_FAIL'],
|
||||
closable: true,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="profile">
|
||||
<div class="flex items-center gap-2 mb-5 text-xl">
|
||||
<span class="text-secondary">
|
||||
<IconUserCircle size={30} />
|
||||
</span>
|
||||
{language.ui.profile}
|
||||
</div>
|
||||
<div class="card w-full bg-base-100 shadow-lg border border-gray-200">
|
||||
<div class="card-body">
|
||||
<form autoComplete="off" use:form>
|
||||
<p class="card-title">{language.ui.changeInfo}</p>
|
||||
<div class="form-content py-5">
|
||||
<TextInput
|
||||
icon={IconUser}
|
||||
name="name"
|
||||
label={language.ui.displayName}
|
||||
value={userInfo?.name}
|
||||
placeholder={language.ui.displayName}
|
||||
error={errors('name')}
|
||||
/>
|
||||
<TextInput
|
||||
icon={IconKey}
|
||||
name="password"
|
||||
type="password"
|
||||
label={language.ui.newPassword}
|
||||
placeholder={language.ui.newPassword}
|
||||
error={errors('password')}
|
||||
/>
|
||||
<TextInput
|
||||
icon={IconKey}
|
||||
name="confirm-password"
|
||||
type="password"
|
||||
label={language.ui.confirmNewPassword}
|
||||
placeholder={language.ui.confirmNewPassword}
|
||||
error={errors('confirm-password')}
|
||||
/>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{language.ui.save}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { default } from './Profile'
|
@ -1,132 +0,0 @@
|
||||
import { For } from 'solid-js'
|
||||
import './warehouse.scss'
|
||||
|
||||
const data = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Bếp',
|
||||
listBox: [
|
||||
{
|
||||
id: 1,
|
||||
code: 'bep-01',
|
||||
name: 'Dụng cụ bếp',
|
||||
items: ['dao', 'muỗng', 'đũa'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
code: 'bep-02',
|
||||
name: 'Dụng cụ bếp',
|
||||
items: ['nồi 20"', 'chảo 20"', 'chén nhỏ', 'chén ăn cơm'],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
code: 'bep-02',
|
||||
name: 'Dụng cụ bếp',
|
||||
items: ['nồi 20"', 'chảo 20"', 'chén nhỏ', 'chén ăn cơm'],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
code: 'bep-02',
|
||||
name: 'Dụng cụ bếp',
|
||||
items: ['nồi 20"', 'chảo 20"', 'chén nhỏ', 'chén ăn cơm'],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
code: 'bep-02',
|
||||
name: 'Dụng cụ bếp',
|
||||
items: ['nồi 20"', 'chảo 20"', 'chén nhỏ', 'chén ăn cơm'],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
code: 'bep-02',
|
||||
name: 'Dụng cụ bếp',
|
||||
items: ['nồi 20"', 'chảo 20"', 'chén nhỏ', 'chén ăn cơm'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Bàn làm việc',
|
||||
listBox: [
|
||||
{
|
||||
id: 1,
|
||||
code: 'work-01',
|
||||
name: 'Work 1',
|
||||
items: ['Chuột Logitech MX', 'Tai nghe G503X'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
code: 'work-02',
|
||||
name: 'Work 2',
|
||||
items: ['Ổ cứng SSD 250GB', 'Điện thoại IP6'],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function PackageItem(props) {
|
||||
const { box, index } = { ...props }
|
||||
|
||||
// QRCode.toDataURL(box.code, {width: 200, margin: 1}, function (err, url) {
|
||||
// if (err) throw err
|
||||
// const img = document.getElementById(`${box.code}-${index}`)
|
||||
// console.log(img, url)
|
||||
// img.src = url
|
||||
// })
|
||||
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
class="group/item w-28 h-28 m-[10px] hover:text-white hover:bg-fu-green rounded-[10px] block"
|
||||
classList={{ 'bg-fu-warning/30': !box.items.length, 'bg-fu-warning': box.items.length }}
|
||||
>
|
||||
{/* <div class="text-base font-bold flex align-center justify-center h-[100%]">Nồi 20"</div> */}
|
||||
<div class="qrcode-box">
|
||||
<img id={`${box.code}-${index}`} src="" alt="" />
|
||||
</div>
|
||||
<div class="qrcode-box" />
|
||||
<div class="bx-dec invisible group-hover/item:visible">
|
||||
<div class="section-dec flex items-center absolute bg-fu-green/90 rounded-[10px] p-[10px] shadow-[0_5px_15px_4px_rgba(0, 135, 90, 0.25)]] mt-[20px] ml-[20px]">
|
||||
{/* <div class="box-img">
|
||||
<Dynamic class="mr-[10px] w-20 h-20 text-fu-warning" component={IconPackage} />
|
||||
</div>
|
||||
<div class="dec max-w-[200px]">
|
||||
<h4 class="text-white mb-[5px] text-base font-bold">{!box.items.length ? 'Rỗng' : box.name}</h4>
|
||||
<p class="line-clamp-3">{box.items.join(', ')}</p>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Warehouse() {
|
||||
return (
|
||||
<div class="locations">
|
||||
<div class="card w-full bg-base-100 shadow-lg border border-gray-200">
|
||||
<div class="card-body">
|
||||
<div class="box-header pb-5 mb-5 flex items-center justify-between border-b border-gray-200">
|
||||
<h4 class="card-title">Nhà 1</h4>
|
||||
<div class="box-controls pull-right">
|
||||
<input class="form-control no-border bg-base-200 px-2 py-1" id="e" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="area-in-warehouse grid grid-cols-3 gap-3">
|
||||
<For each={data}>
|
||||
{(warehouse) => (
|
||||
<div class="card w-full bg-base-100 shadow-lg border border-gray-200 p-1">
|
||||
<div class="area-block mb-10">
|
||||
<div class="area-title text-center text-base font-bold mb-3 mt-3 font-size">{warehouse.name}</div>
|
||||
<div class="area-content flex justify-space-around flex-wrap">
|
||||
<For each={warehouse.listBox}>{(box, index) => <PackageItem box={box} index={index()} />}</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { default } from './Warehouse'
|
@ -1,3 +0,0 @@
|
||||
.warehouse {
|
||||
|
||||
}
|
@ -1 +1,2 @@
|
||||
export * from './nav-route'
|
||||
export * from './routes'
|
||||
|
27
frontend/src/routes/nav-route.js
Normal file
27
frontend/src/routes/nav-route.js
Normal file
@ -0,0 +1,27 @@
|
||||
import {
|
||||
IconBuildingWarehouse,
|
||||
IconDashboard,
|
||||
IconMapPin,
|
||||
} from '@tabler/icons-react'
|
||||
import { PathConstants } from './routes'
|
||||
|
||||
export const NAV_ITEM = (t, admin = false) => [
|
||||
{
|
||||
pathName: PathConstants.DASHBOARD,
|
||||
show: admin,
|
||||
icon: IconDashboard,
|
||||
text: t('ui_dashboard'),
|
||||
},
|
||||
{
|
||||
pathName: PathConstants.LOCATION,
|
||||
show: true,
|
||||
icon: IconMapPin,
|
||||
text: t('ui_house'),
|
||||
},
|
||||
{
|
||||
pathName: PathConstants.WAREHOUSE,
|
||||
show: false,
|
||||
icon: IconBuildingWarehouse,
|
||||
text: t('ui_warehouse'),
|
||||
},
|
||||
]
|
@ -1,55 +0,0 @@
|
||||
import { lazy } from 'solid-js'
|
||||
|
||||
export const ROUTES = [
|
||||
{
|
||||
name: 'home',
|
||||
path: '/',
|
||||
components: lazy(() => import('@pages/Home')),
|
||||
filter: {},
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
name: 'profile',
|
||||
path: '/me',
|
||||
components: lazy(() => import('@pages/Profile')),
|
||||
filter: {},
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
name: 'dashboard',
|
||||
path: '/dashboard',
|
||||
components: lazy(() => import('@pages/Dashboard')),
|
||||
filter: {},
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
name: 'location',
|
||||
path: '/location',
|
||||
components: lazy(() => import('@pages/House')),
|
||||
filter: {},
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
name: 'create-location',
|
||||
path: '/location/create',
|
||||
components: lazy(() => import('@pages/HouseCreate')),
|
||||
filter: {},
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
name: 'edit-location',
|
||||
path: '/location/edit/:id',
|
||||
components: lazy(() => import('@pages/HouseCreate')),
|
||||
filter: {
|
||||
id: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/,
|
||||
},
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
name: 'warehouse',
|
||||
path: '/warehouse',
|
||||
components: lazy(() => import('@pages/WareHouse')),
|
||||
filter: {},
|
||||
show: true,
|
||||
},
|
||||
]
|
59
frontend/src/routes/routes.jsx
Normal file
59
frontend/src/routes/routes.jsx
Normal file
@ -0,0 +1,59 @@
|
||||
import Dashboard from '@pages/Dashboard'
|
||||
import Home from '@pages/Home'
|
||||
import House from '@pages/House'
|
||||
import HouseCreate from '@pages/HouseCreate'
|
||||
import Layout from '@pages/Layout'
|
||||
import Login from '@pages/Login'
|
||||
import Page404 from '@pages/Page404'
|
||||
import Profile from '@pages/Profile'
|
||||
import { createBrowserRouter } from 'react-router-dom'
|
||||
|
||||
export const PathConstants = {
|
||||
LOGIN: '/login',
|
||||
PROFILE: '/me',
|
||||
DASHBOARD: '/dashboard',
|
||||
LOCATION: '/location',
|
||||
CREATE_LOCATION: '/location/create',
|
||||
EDIT_LOCATION: '/location/edit/:id',
|
||||
WAREHOUSE: '/warehouse',
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: PathConstants.LOGIN,
|
||||
element: <Login />,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <Layout />,
|
||||
errorElement: <Page404 />,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: PathConstants.PROFILE,
|
||||
element: <Profile />,
|
||||
},
|
||||
{
|
||||
path: PathConstants.DASHBOARD,
|
||||
element: <Dashboard />,
|
||||
},
|
||||
{
|
||||
path: PathConstants.LOCATION,
|
||||
element: <House />,
|
||||
},
|
||||
{
|
||||
path: PathConstants.CREATE_LOCATION,
|
||||
element: <HouseCreate />,
|
||||
},
|
||||
{
|
||||
path: PathConstants.EDIT_LOCATION,
|
||||
element: <HouseCreate />,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
export default router
|
@ -1,14 +0,0 @@
|
||||
import { STORE_KEY } from './enum'
|
||||
import { Helpers } from './helper'
|
||||
|
||||
export default class UserHelper {
|
||||
#user
|
||||
|
||||
constructor() {
|
||||
this.#user = Helpers.decrypt(localStorage.getItem(STORE_KEY))
|
||||
}
|
||||
|
||||
get isAdmin() {
|
||||
return this.#user?.userInfo?.isAdmin
|
||||
}
|
||||
}
|
@ -1,5 +1,11 @@
|
||||
// const PRODUCTION = import.meta.env.NODE_ENV === 'production'
|
||||
|
||||
export const SECRET_KEY = 'bGV0IGRvIGl0IGZvciBlbmNyeXRo'
|
||||
export const STORE_KEY = 'dXNlciBsb2dpbiBpbmZv'
|
||||
export const LOGIN_KEY = '7fo24CMyIc'
|
||||
export const VIEWDATA = Object.freeze(
|
||||
new Proxy(
|
||||
{ list: 'list', grid: 'grid' },
|
||||
{
|
||||
get: (target, prop) => target[prop] ?? 'list',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { ROUTES } from '@routes/routes'
|
||||
import { AES, enc } from 'crypto-js'
|
||||
import { LOGIN_KEY, SECRET_KEY, STORE_KEY } from './enum'
|
||||
|
||||
@ -51,7 +50,11 @@ export class Helpers {
|
||||
|
||||
static clearObject = (object) => {
|
||||
for (var propName in object) {
|
||||
if (object[propName] === null || object[propName] === undefined) {
|
||||
if (
|
||||
object[propName] === null ||
|
||||
object[propName] === undefined ||
|
||||
object[propName] === ''
|
||||
) {
|
||||
delete object[propName]
|
||||
}
|
||||
}
|
||||
@ -70,7 +73,4 @@ export class Helpers {
|
||||
|
||||
return array.length > 0 ? array : null
|
||||
}
|
||||
|
||||
static getRoutePath = (pathName) =>
|
||||
ROUTES.filter((r) => r.name === pathName)[0].path
|
||||
}
|
||||
|
@ -1,41 +0,0 @@
|
||||
import daisyui from 'daisyui'
|
||||
import themes from 'daisyui/src/theming/themes'
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{js,jsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
screens: {
|
||||
xs: '400px',
|
||||
},
|
||||
colors: {
|
||||
fu: {
|
||||
white: '#fff',
|
||||
black: '#212121',
|
||||
primary: '#03c9d7',
|
||||
green: '#05b187',
|
||||
orange: '#fb9678',
|
||||
warning: '#fbc66c',
|
||||
yellow: '#fec90f',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [daisyui],
|
||||
daisyui: {
|
||||
themes: [
|
||||
{
|
||||
light: {
|
||||
...themes['light'],
|
||||
primary: '#03c9d7',
|
||||
'primary-content': '#ffffff',
|
||||
secondary: '#fb9678',
|
||||
'secondary-content': '#ffffff',
|
||||
neutral: '#03c9d7',
|
||||
'neutral-content': '#ffffff',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
@ -1,17 +1,16 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path, { dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
// import mkcert from 'vite-plugin-mkcert'
|
||||
import solid from 'vite-plugin-solid'
|
||||
|
||||
const _dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
// production
|
||||
export default defineConfig(({ mode }) => {
|
||||
// eslint-disable-next-line no-undef
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
// production
|
||||
if (env.NODE_ENV === 'production') {
|
||||
return {
|
||||
resolve: {
|
||||
@ -28,7 +27,14 @@ export default defineConfig(({ mode }) => {
|
||||
'@context': path.resolve(_dirname, './src/context'),
|
||||
},
|
||||
},
|
||||
plugins: [solid()],
|
||||
plugins: [react()],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: '@import "./src/_mantine";',
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
https: false,
|
||||
host: false,
|
||||
@ -56,10 +62,15 @@ export default defineConfig(({ mode }) => {
|
||||
'@context': path.resolve(_dirname, './src/context'),
|
||||
},
|
||||
},
|
||||
// plugins: [solid(), mkcert()],
|
||||
plugins: [solid()],
|
||||
plugins: [react()],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: '@import "./src/_mantine";',
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
open: true,
|
||||
https: false,
|
||||
host: true,
|
||||
port: 5001,
|
||||
|
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user