Completed Change to ReactJS #5

Merged
sam merged 1 commits from change-fw-solidjs-to-react into develop 2024-09-03 07:49:01 +00:00
89 changed files with 4190 additions and 3866 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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',
},
},
},
}

View 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."
}

View 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ự"
}

View File

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

View File

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

View File

@ -0,0 +1 @@
// for mixin

View File

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

View File

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

View 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

View File

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

View File

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

View File

@ -1,2 +0,0 @@
export * from './AreaAdd'
export { default } from './AreaAdd'

View 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>
</>
)
}

View 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>
)
}

View 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>
)

View 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>
)
}

View 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>
)
}

View File

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

View 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>
</>
)
}

View File

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

View File

@ -1,2 +0,0 @@
export * from './Navbar'
export { default } from './Navbar'

View File

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

View File

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

View File

@ -1,2 +0,0 @@
export * from './ViewSwitch'
export { default } from './ViewSwitch'

View File

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

View File

@ -1,2 +0,0 @@
export * from './ConfirmPopup'
export { default } from './ConfirmPopup'

View File

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

View File

@ -1,2 +0,0 @@
export * from './Pagination'
export { default } from './Pagination'

View File

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

View File

@ -1,2 +0,0 @@
export * from './Popup'
export { default } from './Popup'

View File

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

View File

@ -1 +0,0 @@
export { default } from './TextInput'

View File

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

View File

@ -1,2 +0,0 @@
export * from './Textarea'
export { default } from './Textarea'

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
View 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>,
)

View File

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

View 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>
)
}

View File

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

View File

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

View File

@ -1 +0,0 @@
export { default } from './House'

View 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>
)
}

View File

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

View File

@ -1,2 +0,0 @@
export * from './HouseCreate'
export { default } from './HouseCreate'

View File

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

View File

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

View 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;
}
}

View File

@ -1 +1,2 @@
export * from './Login'
export { default } from './Login'

View File

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

View File

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

View File

@ -1 +0,0 @@
export { default } from './NotFound'

View 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>
</>
)
}

View 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>
)
}

View File

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

View File

@ -1 +0,0 @@
export { default } from './Profile'

View File

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

View File

@ -1 +0,0 @@
export { default } from './Warehouse'

View File

@ -1,3 +0,0 @@
.warehouse {
}

View File

@ -1 +1,2 @@
export * from './nav-route'
export * from './routes'

View 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'),
},
]

View File

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

View 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

View File

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

View File

@ -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',
},
),
)

View File

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

View File

@ -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',
},
},
],
},
}

View File

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