✨ frontend add
This commit is contained in:
2
frontend/README.md
Normal file
2
frontend/README.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# egovframe-msa-template-frontend
|
||||||
|
msa template frontend - 클라우드 네이티브 기반의 행정,공공기관 서비스 확산 지원 사업
|
||||||
22
frontend/admin/.babelrc
Normal file
22
frontend/admin/.babelrc
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"presets": ["next/babel"],
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"module-resolver",
|
||||||
|
{
|
||||||
|
"root": ["./"],
|
||||||
|
"alias": {
|
||||||
|
"@components": "./src/components",
|
||||||
|
"@pages": "./src/pages",
|
||||||
|
"@styles": "./src/styles",
|
||||||
|
"@hooks": "./src/hooks",
|
||||||
|
"@constants": "./src/constants",
|
||||||
|
"@stores": "./src/stores",
|
||||||
|
"@service": "./src/service",
|
||||||
|
"@libs": "./src/libs",
|
||||||
|
"@utils": "./src/utils"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
63
frontend/admin/.eslintrc.js
Normal file
63
frontend/admin/.eslintrc.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
node: true,
|
||||||
|
es2020: true,
|
||||||
|
jest: true, //jest 사용시에만 추가
|
||||||
|
},
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
sourceType: 'module',
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint', 'react', 'prettier'],
|
||||||
|
extends: [
|
||||||
|
'airbnb',
|
||||||
|
'airbnb/hooks',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:react/recommended',
|
||||||
|
'plugin:import/errors',
|
||||||
|
'plugin:import/warnings',
|
||||||
|
'plugin:import/typescript',
|
||||||
|
'prettier',
|
||||||
|
'prettier/@typescript-eslint',
|
||||||
|
'prettier/react',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'react/jsx-filename-extension': [1, { extensions: ['.ts', '.tsx'] }],
|
||||||
|
'import/extensions': 'off',
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
'jsx-a11y/anchor-is-valid': 'off',
|
||||||
|
'react/jsx-props-no-spreading': ['error', { custom: 'ignore' }],
|
||||||
|
'prettier/prettier': 'error',
|
||||||
|
'react/no-unescaped-entities': 'off',
|
||||||
|
'import/no-cycle': [0, { ignoreExternal: true }],
|
||||||
|
'prefer-const': 'off',
|
||||||
|
// needed because of https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-use-before-define.md#how-to-use & https://stackoverflow.com/questions/63818415/react-was-used-before-it-was-defined
|
||||||
|
'no-use-before-define': 'off',
|
||||||
|
'@typescript-eslint/no-use-before-define': [
|
||||||
|
'error',
|
||||||
|
{ functions: false, classes: false, variables: true },
|
||||||
|
],
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: ['@material-ui/*/*/*', '!@material-ui/core/test-utils/*'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
'import/resolver': {
|
||||||
|
'babel-module': {
|
||||||
|
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||||
|
},
|
||||||
|
node: {
|
||||||
|
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||||
|
paths: ['src'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
16
frontend/admin/.prettierrc.js
Normal file
16
frontend/admin/.prettierrc.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module.exports = {
|
||||||
|
singleQuote: true,
|
||||||
|
// 문자열은 따옴표로 formatting
|
||||||
|
semi: false,
|
||||||
|
//코드 마지막에 세미콜른이 없도록 formatting
|
||||||
|
useTabs: false,
|
||||||
|
//탭의 사용을 금하고 스페이스바 사용으로 대체하게 formatting
|
||||||
|
tabWidth: 2,
|
||||||
|
// 들여쓰기 너비는 2칸
|
||||||
|
trailingComma: 'all',
|
||||||
|
// 배열 키:값 뒤에 항상 콤마를 붙히도록 //formatting
|
||||||
|
printWidth: 80,
|
||||||
|
// 코드 한줄이 maximum 80칸
|
||||||
|
arrowParens: 'avoid',
|
||||||
|
// 화살표 함수가 하나의 매개변수를 받을 때 괄호를 생략하게 formatting
|
||||||
|
}
|
||||||
13
frontend/admin/Dockerfile
Normal file
13
frontend/admin/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# admin
|
||||||
|
FROM node:14.8.0-alpine
|
||||||
|
|
||||||
|
ENV APP_HOME=/usr/app/
|
||||||
|
RUN mkdir -p ${APP_HOME}
|
||||||
|
# 작업 시작 위치
|
||||||
|
WORKDIR $APP_HOME
|
||||||
|
COPY package*.json .
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
56
frontend/admin/README.md
Normal file
56
frontend/admin/README.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Frontend Admin Boilerplate
|
||||||
|
|
||||||
|
Next.js + typescript + material ui 활용한 admin dashboard Boilerplate.
|
||||||
|
|
||||||
|
[notion link](https://www.notion.so/Nextjs-MUI-Admin-template-bc57d86c94724bbf83601883c2d5ec13)
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 폴더 구조
|
||||||
|
|
||||||
|
```bash
|
||||||
|
├─public # static resource root
|
||||||
|
│ └─images
|
||||||
|
│
|
||||||
|
├─server # custom server
|
||||||
|
│ └─index.ts
|
||||||
|
│
|
||||||
|
├─src # source root
|
||||||
|
│ ├─@types # type declaration
|
||||||
|
│ ├─components # components
|
||||||
|
│ ├─constants # constants
|
||||||
|
│ ├─hooks # custom hooks
|
||||||
|
│ ├─lib # deps library custom
|
||||||
|
│ ├─pages # next.js page routing
|
||||||
|
│ │ ├─api # next.js api routing
|
||||||
|
│ │ └─auth # 로그인 관련
|
||||||
|
│ ├─store # recoil 상태관리
|
||||||
|
│ └─styles # global styles
|
||||||
|
│
|
||||||
|
├─test # test 관련
|
||||||
|
│
|
||||||
|
│ .babelrc # babel config
|
||||||
|
│ .env.local # environment variables
|
||||||
|
│ .eslintrc.js # eslint config
|
||||||
|
│ .gitignore # git ignore
|
||||||
|
│ .prettierrc.js # prettier config
|
||||||
|
│ jest.config.js # jest config
|
||||||
|
│ jest.setup.ts # jest에서 testing-library 사용하기 위한 설정(그외 jest에 필요한 외부 라이브러리 설정)
|
||||||
|
│ next-env.d.ts # next.js type declare
|
||||||
|
│ next.config.js # next.js config
|
||||||
|
│ package.json
|
||||||
|
│ README.md
|
||||||
|
│ tsconfig.json # typescirpt config
|
||||||
|
└ tsconfig.server.json # custom server 사용 시 typescript config
|
||||||
|
|
||||||
|
```
|
||||||
11
frontend/admin/jest.config.js
Normal file
11
frontend/admin/jest.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = {
|
||||||
|
testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
|
||||||
|
setupFilesAfterEnv: ['./jest.setup.ts'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
|
||||||
|
'<rootDir>/test/mocks.ts',
|
||||||
|
'\\.(css|less|scss|html)$': '<rootDir>/test/mocks.ts',
|
||||||
|
//절대 경로 세팅한 경우 jest에도 세팅이 필요함
|
||||||
|
'^@components(.*)$': '<rootDir>/components$1',
|
||||||
|
},
|
||||||
|
}
|
||||||
1
frontend/admin/jest.setup.ts
Normal file
1
frontend/admin/jest.setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom'
|
||||||
13
frontend/admin/manifest.yml
Normal file
13
frontend/admin/manifest.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
applications:
|
||||||
|
- name: egov-simple-admin # CF push 시 생성되는 이름
|
||||||
|
memory: 2048M # 메모리
|
||||||
|
instances: 1 # 인스턴스 수
|
||||||
|
host: egov-simple-admin # host 명으로 유일해야 함
|
||||||
|
command: npm run start # 애플리케이션 실행 명령어
|
||||||
|
path: ./ # 배포될 애플리케이션의 위치
|
||||||
|
buildpack: nodejs_buildpack # cf buildpacks 명령어로 nodejs buildpack 이름 확인
|
||||||
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
TZ: 'Asia/Seoul'
|
||||||
|
# SERVER_API_URL: https://egov-apigateway.paas-ta.org
|
||||||
6
frontend/admin/next-env.d.ts
vendored
Normal file
6
frontend/admin/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/types/global" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
6
frontend/admin/next-i18next.config.js
Normal file
6
frontend/admin/next-i18next.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
i18n: {
|
||||||
|
locales: ['ko', 'en'],
|
||||||
|
defaultLocale: 'ko',
|
||||||
|
},
|
||||||
|
}
|
||||||
42
frontend/admin/next.config.js
Normal file
42
frontend/admin/next.config.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const { i18n } = require('./next-i18next.config')
|
||||||
|
|
||||||
|
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||||
|
enabled: process.env.ANALYZE === 'true',
|
||||||
|
})
|
||||||
|
const withPlugins = require('next-compose-plugins')
|
||||||
|
|
||||||
|
const plugins = [[withBundleAnalyzer]]
|
||||||
|
const serverApiUrl = process.env.SERVER_API_URL || 'http://localhost:8000'
|
||||||
|
const siteId = process.env.SITE_ID || '1'
|
||||||
|
const port = process.env.PORT || '3000'
|
||||||
|
|
||||||
|
const nextConfig = {
|
||||||
|
i18n,
|
||||||
|
env: {
|
||||||
|
SERVER_API_URL: serverApiUrl,
|
||||||
|
PORT: port,
|
||||||
|
PROXY_HOST: process.env.PROXY_HOST || `http://localhost:${port}`,
|
||||||
|
ENV: process.env.ENV || '-',
|
||||||
|
SITE_ID: siteId,
|
||||||
|
},
|
||||||
|
webpack: (config, { webpack }) => {
|
||||||
|
const prod = process.env.NODE_ENV === 'production'
|
||||||
|
const newConfig = {
|
||||||
|
...config,
|
||||||
|
mode: prod ? 'production' : 'development',
|
||||||
|
}
|
||||||
|
if (prod) {
|
||||||
|
newConfig.devtool = 'hidden-source-map'
|
||||||
|
}
|
||||||
|
return newConfig
|
||||||
|
},
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/server/:path*',
|
||||||
|
destination: `${serverApiUrl}/:path*`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
module.exports = withPlugins(plugins, nextConfig)
|
||||||
20461
frontend/admin/package-lock.json
generated
Normal file
20461
frontend/admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
101
frontend/admin/package.json
Normal file
101
frontend/admin/package.json
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"name": "msa-template-admin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "14.8.0",
|
||||||
|
"npm": "6.14.7"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start:dev": "next",
|
||||||
|
"dev": "ts-node --project tsconfig.server.json server/index.ts",
|
||||||
|
"dev:sm": "SITE_ID=4 ts-node --project tsconfig.server.json server/index.ts",
|
||||||
|
"clean:dev": "rimraf .next",
|
||||||
|
"build:server": "tsc --project tsconfig.server.json",
|
||||||
|
"build:next": "next build",
|
||||||
|
"prebuild": "rimraf ./dist",
|
||||||
|
"build": "npm run build:next && npm run build:server",
|
||||||
|
"build:prodlg": "env-cmd -f ./.env.production-lg npm run build:next && npm run build:server",
|
||||||
|
"start:prodlg": "env-cmd -f ./.env.production-lg node dist/index.js",
|
||||||
|
"build:prodsm": "env-cmd -f ./.env.production-sm npm run build:next && npm run build:server",
|
||||||
|
"start:prodsm": "env-cmd -f ./.env.production-sm node dist/index.js",
|
||||||
|
"start": "NODE_ENV=production node dist/index.js",
|
||||||
|
"test": "jest --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atlaskit/tree": "^8.4.0",
|
||||||
|
"@ckeditor/ckeditor5-build-classic": "^29.1.0",
|
||||||
|
"@ckeditor/ckeditor5-react": "^3.0.2",
|
||||||
|
"@date-io/date-fns": "^2.11.0",
|
||||||
|
"@hookform/resolvers": "^2.6.1",
|
||||||
|
"@material-ui/core": "^4.12.2",
|
||||||
|
"@material-ui/data-grid": "4.0.0-alpha.37",
|
||||||
|
"@material-ui/icons": "^4.11.2",
|
||||||
|
"@material-ui/lab": "4.0.0-alpha.60",
|
||||||
|
"@material-ui/pickers": "^3.3.10",
|
||||||
|
"@material-ui/styles": "^4.11.4",
|
||||||
|
"@next/bundle-analyzer": "^11.1.0",
|
||||||
|
"@types/form-data": "^2.5.0",
|
||||||
|
"axios": "^0.21.1",
|
||||||
|
"classnames": "^2.3.1",
|
||||||
|
"cookies": "^0.8.0",
|
||||||
|
"date-fns": "^2.23.0",
|
||||||
|
"date-fns-tz": "^1.1.6",
|
||||||
|
"express": "^4.17.1",
|
||||||
|
"i18next": "^20.4.0",
|
||||||
|
"immer": "^9.0.5",
|
||||||
|
"multer": "^1.4.3",
|
||||||
|
"next": "11.1.0",
|
||||||
|
"next-compose-plugins": "^2.2.1",
|
||||||
|
"next-connect": "^0.10.2",
|
||||||
|
"next-i18next": "^8.6.0",
|
||||||
|
"notistack": "^1.0.10",
|
||||||
|
"querystring": "^0.2.1",
|
||||||
|
"react": "17.0.2",
|
||||||
|
"react-beautiful-dnd": "^13.1.0",
|
||||||
|
"react-cookie": "^4.1.1",
|
||||||
|
"react-datepicker": "^4.2.1",
|
||||||
|
"react-dom": "17.0.2",
|
||||||
|
"react-hook-form": "^7.13.0",
|
||||||
|
"react-i18next": "^11.11.4",
|
||||||
|
"recharts": "^2.1.2",
|
||||||
|
"recoil": "^0.4.1",
|
||||||
|
"swr": "^0.5.6",
|
||||||
|
"uuid": "^8.3.2",
|
||||||
|
"yup": "^0.32.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/dom": "^8.2.0",
|
||||||
|
"@testing-library/jest-dom": "^5.14.1",
|
||||||
|
"@testing-library/react": "^12.0.0",
|
||||||
|
"@types/classnames": "^2.3.1",
|
||||||
|
"@types/cookies": "^0.7.7",
|
||||||
|
"@types/express": "^4.17.13",
|
||||||
|
"@types/multer": "^1.4.7",
|
||||||
|
"@types/node": "^16.7.2",
|
||||||
|
"@types/react": "^17.0.19",
|
||||||
|
"@types/react-beautiful-dnd": "^13.1.1",
|
||||||
|
"@types/react-cookies": "^0.1.0",
|
||||||
|
"@types/react-datepicker": "^4.1.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^4.29.3",
|
||||||
|
"@typescript-eslint/parser": "^4.29.3",
|
||||||
|
"babel-jest": "^27.0.6",
|
||||||
|
"babel-plugin-module-resolver": "^4.1.0",
|
||||||
|
"env-cmd": "^10.1.0",
|
||||||
|
"eslint": "^7.32.0",
|
||||||
|
"eslint-config-airbnb": "^18.2.1",
|
||||||
|
"eslint-config-next": "^11.1.0",
|
||||||
|
"eslint-config-prettier": "^8.3.0",
|
||||||
|
"eslint-import-resolver-babel-module": "^5.3.1",
|
||||||
|
"eslint-plugin-import": "^2.24.2",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||||
|
"eslint-plugin-prettier": "^3.4.1",
|
||||||
|
"eslint-plugin-react": "^7.24.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.2.0",
|
||||||
|
"jest": "^27.0.6",
|
||||||
|
"prettier": "^2.3.2",
|
||||||
|
"rimraf": "^3.0.2",
|
||||||
|
"ts-node": "latest",
|
||||||
|
"typescript": "^4.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/admin/public/favicon.ico
Normal file
BIN
frontend/admin/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/admin/public/images/adminLogo.png
Normal file
BIN
frontend/admin/public/images/adminLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/admin/public/images/favicon-96x96.png
Normal file
BIN
frontend/admin/public/images/favicon-96x96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
1
frontend/admin/public/locales/en/common.json
Normal file
1
frontend/admin/public/locales/en/common.json
Normal file
File diff suppressed because one or more lines are too long
1
frontend/admin/public/locales/ko/common.json
Normal file
1
frontend/admin/public/locales/ko/common.json
Normal file
File diff suppressed because one or more lines are too long
4
frontend/admin/public/vercel.svg
Normal file
4
frontend/admin/public/vercel.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
30
frontend/admin/server/index.ts
Normal file
30
frontend/admin/server/index.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { loadEnvConfig } from '@next/env'
|
||||||
|
import express, { Request, Response } from 'express'
|
||||||
|
import next from 'next'
|
||||||
|
loadEnvConfig('./', process.env.NODE_ENV !== 'production')
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3000
|
||||||
|
const dev = process.env.NODE_ENV !== 'production'
|
||||||
|
const app = next({ dev })
|
||||||
|
const handle = app.getRequestHandler()
|
||||||
|
|
||||||
|
const startServer = async () => {
|
||||||
|
try {
|
||||||
|
await app.prepare()
|
||||||
|
const server = express()
|
||||||
|
|
||||||
|
server.all('*', (req: Request, res: Response) => {
|
||||||
|
return handle(req, res)
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(port, (err?: any) => {
|
||||||
|
if (err) throw err
|
||||||
|
console.log(`> Ready on localhost:${port} - env ${process.env.NODE_ENV}`)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer()
|
||||||
29
frontend/admin/src/@types/global.d.ts
vendored
Normal file
29
frontend/admin/src/@types/global.d.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
interface Window {
|
||||||
|
__localeId__: string
|
||||||
|
}
|
||||||
|
namespace NodeJS {
|
||||||
|
interface Global {
|
||||||
|
__localeId__: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.png' {
|
||||||
|
const resource: string
|
||||||
|
export = resource
|
||||||
|
}
|
||||||
|
declare module '*.svg' {
|
||||||
|
const resource: string
|
||||||
|
export = resource
|
||||||
|
}
|
||||||
|
declare module '*.css' {
|
||||||
|
const resource: any
|
||||||
|
export = resource
|
||||||
|
}
|
||||||
|
declare module '*.pcss' {
|
||||||
|
const resource: string
|
||||||
|
export = resource
|
||||||
|
}
|
||||||
|
declare module '*.json' {
|
||||||
|
const resource: any
|
||||||
|
export = resource
|
||||||
|
}
|
||||||
193
frontend/admin/src/components/App/App.tsx
Normal file
193
frontend/admin/src/components/App/App.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { Layout } from '@components/Layout'
|
||||||
|
import Loader from '@components/Loader'
|
||||||
|
import LoginLayout from '@components/LoginLayout'
|
||||||
|
import Wrapper from '@components/Wrapper'
|
||||||
|
import {
|
||||||
|
ACCESS_LOG_ID,
|
||||||
|
ACCESS_LOG_TIMEOUT,
|
||||||
|
DEFAULT_APP_NAME,
|
||||||
|
DEFAULT_ERROR_MESSAGE,
|
||||||
|
PUBLIC_PAGES,
|
||||||
|
} from '@constants'
|
||||||
|
import { SITE_ID } from '@constants/env'
|
||||||
|
import useUser from '@hooks/useUser'
|
||||||
|
import { getCurrentDate } from '@libs/date'
|
||||||
|
import { common, statisticsService } from '@service'
|
||||||
|
import {
|
||||||
|
currentMenuStateAtom,
|
||||||
|
flatMenusSelect,
|
||||||
|
ISideMenu,
|
||||||
|
menuStateAtom,
|
||||||
|
} from '@stores'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { NextComponentType, NextPageContext } from 'next'
|
||||||
|
import Head from 'next/head'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useSnackbar } from 'notistack'
|
||||||
|
import React, { useCallback, useEffect } from 'react'
|
||||||
|
import { useCookies } from 'react-cookie'
|
||||||
|
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||||
|
import { SWRConfig } from 'swr'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
|
export type AppProps = {
|
||||||
|
component: NextComponentType<any, any, any>
|
||||||
|
pathname?: string
|
||||||
|
req?: NextPageContext['req']
|
||||||
|
}
|
||||||
|
|
||||||
|
const App = ({ component: Component, ...pageProps }: AppProps) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = router.pathname
|
||||||
|
const authLayout = pathname.startsWith('/auth/')
|
||||||
|
const isUnAuthPage = pathname !== undefined && authLayout
|
||||||
|
|
||||||
|
const { user, loading, isLogin, loggedOut } = useUser()
|
||||||
|
|
||||||
|
const [menus, setMenus] = useRecoilState(menuStateAtom)
|
||||||
|
const [currentMenu, setCurrentMenu] = useRecoilState(currentMenuStateAtom)
|
||||||
|
const flatMenus = useRecoilValue(flatMenusSelect)
|
||||||
|
|
||||||
|
const { enqueueSnackbar } = useSnackbar()
|
||||||
|
|
||||||
|
const [cookies, setCookie] = useCookies([ACCESS_LOG_ID])
|
||||||
|
|
||||||
|
// access log
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLayout) {
|
||||||
|
const date = getCurrentDate()
|
||||||
|
date.setTime(date.getTime() + ACCESS_LOG_TIMEOUT)
|
||||||
|
if (cookies[ACCESS_LOG_ID]) {
|
||||||
|
setCookie(ACCESS_LOG_ID, cookies[ACCESS_LOG_ID], {
|
||||||
|
path: '/',
|
||||||
|
expires: date,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const uuid = uuidv4()
|
||||||
|
setCookie(ACCESS_LOG_ID, uuid, { path: '/', expires: date })
|
||||||
|
try {
|
||||||
|
statisticsService.save(uuid)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('access log save error', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loggedOut) {
|
||||||
|
router.replace('/auth/login')
|
||||||
|
}
|
||||||
|
}, [loggedOut])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !isUnAuthPage && user === undefined) {
|
||||||
|
router.replace('/auth/login')
|
||||||
|
}
|
||||||
|
}, [user, isUnAuthPage, pathname, loading])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLogin) {
|
||||||
|
const getMenus = async () => {
|
||||||
|
const menu = await axios.get(
|
||||||
|
`/portal-service/api/v1/menu-roles/${SITE_ID}`,
|
||||||
|
{
|
||||||
|
headers: common.headers,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (menu) {
|
||||||
|
setMenus(menu.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getMenus()
|
||||||
|
}
|
||||||
|
}, [isLogin])
|
||||||
|
|
||||||
|
//current menu
|
||||||
|
const findCurrent = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
return flatMenus.find(item => item.urlPath === path)
|
||||||
|
},
|
||||||
|
[menus, pathname],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUnAuthPage) {
|
||||||
|
let current: ISideMenu | undefined = undefined
|
||||||
|
let paths = router.asPath
|
||||||
|
while (true) {
|
||||||
|
current = findCurrent(paths)
|
||||||
|
paths = paths.substring(0, paths.lastIndexOf('/'))
|
||||||
|
if (current || paths.length < 1) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 권한 없는 페이지 대해 호출이 있으면 404로 redirect
|
||||||
|
if (flatMenus.length > 0 && !current) {
|
||||||
|
if (!PUBLIC_PAGES.includes(router.asPath)) {
|
||||||
|
router.push('/404')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentMenu(current)
|
||||||
|
}
|
||||||
|
}, [pathname, menus])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loader />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUnAuthPage && user == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUnAuthPage && !user) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUnAuthPage && !(currentMenu || PUBLIC_PAGES.includes(router.asPath))) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{currentMenu?.korName || DEFAULT_APP_NAME}</title>
|
||||||
|
</Head>
|
||||||
|
{pathname !== undefined && authLayout ? (
|
||||||
|
<LoginLayout>
|
||||||
|
<Wrapper>
|
||||||
|
<Component pathname={pathname} {...pageProps} />
|
||||||
|
</Wrapper>
|
||||||
|
</LoginLayout>
|
||||||
|
) : (
|
||||||
|
<Layout>
|
||||||
|
<SWRConfig
|
||||||
|
value={{
|
||||||
|
onError: (error, key) => {
|
||||||
|
let message: string
|
||||||
|
if (error.response) {
|
||||||
|
message = error.response.data.message || DEFAULT_ERROR_MESSAGE
|
||||||
|
} else {
|
||||||
|
message = DEFAULT_ERROR_MESSAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueueSnackbar(message, {
|
||||||
|
variant: 'error',
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Wrapper>
|
||||||
|
<Component pathname={pathname} {...pageProps} />
|
||||||
|
</Wrapper>
|
||||||
|
</SWRConfig>
|
||||||
|
</Layout>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
84
frontend/admin/src/components/AttachList/index.tsx
Normal file
84
frontend/admin/src/components/AttachList/index.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import Chip from '@material-ui/core/Chip'
|
||||||
|
import Paper from '@material-ui/core/Paper'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import AssignmentReturnedIcon from '@material-ui/icons/AssignmentReturned'
|
||||||
|
import { fileService, IAttachmentResponse } from '@service'
|
||||||
|
import produce from 'immer'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: theme.spacing(0),
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
margin: theme.spacing(0.5),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface AttachListProps {
|
||||||
|
data: IAttachmentResponse[]
|
||||||
|
setData?: React.Dispatch<React.SetStateAction<IAttachmentResponse[]>>
|
||||||
|
readonly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const AttachList = (props: AttachListProps) => {
|
||||||
|
const { data, setData, readonly } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const handleClick = (item: IAttachmentResponse) => () => {
|
||||||
|
let a = document.createElement('a')
|
||||||
|
a.href = `${fileService.downloadUrl}/${item.id}`
|
||||||
|
a.download = item.originalFileName
|
||||||
|
a.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (item: IAttachmentResponse) => () => {
|
||||||
|
setData(
|
||||||
|
produce(data, draft => {
|
||||||
|
const idx = draft.findIndex(attachment => attachment.id === item.id)
|
||||||
|
draft[idx].isDelete = true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper component="ul" className={classes.root}>
|
||||||
|
{data &&
|
||||||
|
data.map(item => {
|
||||||
|
return item.isDelete ? null : (
|
||||||
|
<li key={`li-${item.id}`} className={classes.item}>
|
||||||
|
{readonly ? (
|
||||||
|
<Chip
|
||||||
|
key={`chip-${item.id}`}
|
||||||
|
label={item.originalFileName}
|
||||||
|
onClick={handleClick(item)}
|
||||||
|
className={classes.chip}
|
||||||
|
icon={<AssignmentReturnedIcon />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
key={`chip-${item.id}`}
|
||||||
|
label={item.originalFileName}
|
||||||
|
onClick={handleClick(item)}
|
||||||
|
onDelete={handleDelete(item)}
|
||||||
|
className={classes.chip}
|
||||||
|
icon={<AssignmentReturnedIcon />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AttachList
|
||||||
168
frontend/admin/src/components/Auth/LoginForm.tsx
Normal file
168
frontend/admin/src/components/Auth/LoginForm.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import Container from '@material-ui/core/Container'
|
||||||
|
import CssBaseline from '@material-ui/core/CssBaseline'
|
||||||
|
import Avatar from '@material-ui/core/Avatar'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
import LockOutlinedIcon from '@material-ui/icons/LockOutlined'
|
||||||
|
import TextField from '@material-ui/core/TextField'
|
||||||
|
import FormControlLabel from '@material-ui/core/FormControlLabel'
|
||||||
|
import Checkbox from '@material-ui/core/Checkbox'
|
||||||
|
import Button from '@material-ui/core/Button'
|
||||||
|
import Alert from '@material-ui/lab/Alert'
|
||||||
|
import { makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { PageProps } from '@pages/_app'
|
||||||
|
import { EmailStorage } from '@libs/Storage/emailStorage'
|
||||||
|
import { DEFAULT_APP_NAME } from '@constants'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) => ({
|
||||||
|
paper: {
|
||||||
|
marginTop: theme.spacing(10),
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
margin: theme.spacing(1),
|
||||||
|
backgroundColor: theme.palette.secondary.main,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
width: '100%', // Fix IE 11 issue.
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
margin: theme.spacing(3, 0, 2),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
export type loginFormType = {
|
||||||
|
email?: string
|
||||||
|
password?: string
|
||||||
|
isRemember?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ILoginFormProps extends PageProps {
|
||||||
|
errorMessage?: string
|
||||||
|
handleLogin: ({ email, password }: loginFormType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginForm = ({ handleLogin, errorMessage }: ILoginFormProps) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const emails = new EmailStorage('login')
|
||||||
|
|
||||||
|
const [checked, setChecked] = useState<boolean>(emails.get().isRemember)
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
getValues,
|
||||||
|
} = useForm<loginFormType>()
|
||||||
|
|
||||||
|
const onSubmit = (formData: loginFormType) => {
|
||||||
|
setRemember()
|
||||||
|
handleLogin({
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setRemember = () => {
|
||||||
|
if (checked) {
|
||||||
|
emails.set({
|
||||||
|
email: getValues('email'),
|
||||||
|
isRemember: checked,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
emails.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setChecked(event.target.checked)
|
||||||
|
setRemember()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container component="main" maxWidth="xs">
|
||||||
|
<CssBaseline />
|
||||||
|
<div className={classes.paper}>
|
||||||
|
<Avatar className={classes.avatar}>
|
||||||
|
<LockOutlinedIcon />
|
||||||
|
</Avatar>
|
||||||
|
<Typography component="h1" variant="h5">
|
||||||
|
{DEFAULT_APP_NAME}
|
||||||
|
</Typography>
|
||||||
|
<form
|
||||||
|
className={classes.form}
|
||||||
|
noValidate
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
variant="outlined"
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
id="email"
|
||||||
|
label="Email Address"
|
||||||
|
name="email"
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
defaultValue={emails.get().email}
|
||||||
|
{...register('email', {
|
||||||
|
required: 'Email Address is required!!',
|
||||||
|
pattern: {
|
||||||
|
value:
|
||||||
|
/^[0-9a-zA-Z]([-_\.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_\.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/i,
|
||||||
|
message: 'Email Address의 형식이 맞지 않습니다.',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<Alert severity="warning">{errors.email.message}</Alert>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
variant="outlined"
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
name="password"
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
{...register('password', {
|
||||||
|
required: 'Password is required!!',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<Alert severity="warning">{errors.password.message}</Alert>
|
||||||
|
)}
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
value="remember"
|
||||||
|
color="primary"
|
||||||
|
onChange={handleChange}
|
||||||
|
checked={checked}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="아이디 저장"
|
||||||
|
/>
|
||||||
|
{errorMessage && <Alert severity="warning">{errorMessage}</Alert>}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
className={classes.submit}
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginForm
|
||||||
233
frontend/admin/src/components/Buttons/CustomButtons.tsx
Normal file
233
frontend/admin/src/components/Buttons/CustomButtons.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSnackbar } from 'notistack'
|
||||||
|
import { useRecoilState } from 'recoil'
|
||||||
|
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import Box from '@material-ui/core/Box'
|
||||||
|
import Button, { ButtonProps } from '@material-ui/core/Button'
|
||||||
|
import Card from '@material-ui/core/Card'
|
||||||
|
import CardActions from '@material-ui/core/CardActions'
|
||||||
|
import CardContent from '@material-ui/core/CardContent'
|
||||||
|
import Popover from '@material-ui/core/Popover'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
|
||||||
|
import { detailButtonsSnackAtom } from '@stores'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
display: 'flex',
|
||||||
|
margin: theme.spacing(1),
|
||||||
|
justifyContent: 'center',
|
||||||
|
'& .MuiButton-root': {
|
||||||
|
margin: theme.spacing(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
containerLeft: {
|
||||||
|
display: 'flex',
|
||||||
|
float: 'left',
|
||||||
|
margin: theme.spacing(1, 0),
|
||||||
|
justifyContent: 'left',
|
||||||
|
'& .MuiButton-root': {
|
||||||
|
margin: theme.spacing(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
containerRight: {
|
||||||
|
display: 'flex',
|
||||||
|
float: 'right',
|
||||||
|
margin: theme.spacing(1, 0),
|
||||||
|
justifyContent: 'right',
|
||||||
|
'& .MuiButton-root': {
|
||||||
|
margin: theme.spacing(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
backdrop: {
|
||||||
|
zIndex: theme.zIndex.drawer + 1,
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
mg0: {
|
||||||
|
margin: theme.spacing(0),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface IButtonProps extends ButtonProps {
|
||||||
|
label: string
|
||||||
|
confirmMessage?: string
|
||||||
|
validate?: (row?: any) => boolean
|
||||||
|
handleButton: (row?: any) => void
|
||||||
|
completeMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICustomButtonProps {
|
||||||
|
buttons: IButtonProps[]
|
||||||
|
row?: any
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomButtons: React.FC<ICustomButtonProps> = ({
|
||||||
|
buttons,
|
||||||
|
row,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const topBoxClass =
|
||||||
|
typeof className !== 'undefined' ? classes[className] : classes.container
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { enqueueSnackbar } = useSnackbar()
|
||||||
|
|
||||||
|
const [isSuccessSnackBar, setSuccessSnackBar] = useRecoilState(
|
||||||
|
detailButtonsSnackAtom,
|
||||||
|
)
|
||||||
|
|
||||||
|
const [buttonId, setButtonId] = useState<string>(null)
|
||||||
|
const [message, setMessage] = useState<string>(null)
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null)
|
||||||
|
const messageOpen = Boolean(anchorEl)
|
||||||
|
const messagePopId = messageOpen ? 'simple-popover' : undefined
|
||||||
|
|
||||||
|
const findButton = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
if (id) {
|
||||||
|
const buttonIndex = parseInt(id.replace('customButton', ''), 10)
|
||||||
|
return buttons[buttonIndex]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
[buttons],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSuccessSnackBar === 'success') {
|
||||||
|
const button = findButton(buttonId)
|
||||||
|
if (button?.completeMessage) {
|
||||||
|
enqueueSnackbar(button.completeMessage || t('msg.success.save'), {
|
||||||
|
variant: 'success',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setSuccessSnackBar('none')
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
buttonId,
|
||||||
|
enqueueSnackbar,
|
||||||
|
findButton,
|
||||||
|
isSuccessSnackBar,
|
||||||
|
setSuccessSnackBar,
|
||||||
|
t,
|
||||||
|
])
|
||||||
|
|
||||||
|
const handlePopover = useCallback(
|
||||||
|
(target: HTMLButtonElement | null) => {
|
||||||
|
if (target?.id) {
|
||||||
|
const button = findButton(target?.id)
|
||||||
|
|
||||||
|
if (button) {
|
||||||
|
setMessage(button.confirmMessage)
|
||||||
|
setAnchorEl(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[findButton],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClick = (target: HTMLButtonElement | null) => {
|
||||||
|
setButtonId(target?.id)
|
||||||
|
|
||||||
|
if (target?.id) {
|
||||||
|
const button = findButton(target?.id)
|
||||||
|
|
||||||
|
if (button) {
|
||||||
|
if (button.validate && !button.validate(row)) return
|
||||||
|
|
||||||
|
if (button.confirmMessage) {
|
||||||
|
handlePopover(target)
|
||||||
|
} else {
|
||||||
|
button.handleButton(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleButton = () => {
|
||||||
|
const button = findButton(anchorEl?.id)
|
||||||
|
if (button) button.handleButton(row)
|
||||||
|
setAnchorEl(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box className={topBoxClass}>
|
||||||
|
{buttons &&
|
||||||
|
buttons.map((button, index) => {
|
||||||
|
const {
|
||||||
|
label,
|
||||||
|
confirmMessage,
|
||||||
|
validate,
|
||||||
|
handleButton,
|
||||||
|
completeMessage,
|
||||||
|
...rest
|
||||||
|
} = button
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={`customButton${index.toString()}`}
|
||||||
|
id={`customButton${index.toString()}`}
|
||||||
|
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
handleClick(event.currentTarget)
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Popover
|
||||||
|
id={messagePopId}
|
||||||
|
open={messageOpen}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={() => {
|
||||||
|
handlePopover(null)
|
||||||
|
}}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'center',
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h5">{message}</Typography>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => {
|
||||||
|
setAnchorEl(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('label.button.close')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
onClick={handleButton}
|
||||||
|
>
|
||||||
|
{t('label.button.confirm')}
|
||||||
|
</Button>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</Popover>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CustomButtons }
|
||||||
112
frontend/admin/src/components/Buttons/DetailButtons.tsx
Normal file
112
frontend/admin/src/components/Buttons/DetailButtons.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import Box from '@material-ui/core/Box'
|
||||||
|
import Button from '@material-ui/core/Button'
|
||||||
|
import { Color } from '@material-ui/lab/Alert'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ConfirmPopover } from '@components/Confirm'
|
||||||
|
import { useSnackbar } from 'notistack'
|
||||||
|
import { detailButtonsSnackAtom } from '@stores'
|
||||||
|
import { useRecoilState } from 'recoil'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
display: 'flex',
|
||||||
|
margin: theme.spacing(1),
|
||||||
|
justifyContent: 'center',
|
||||||
|
'& .MuiButton-root': {
|
||||||
|
margin: theme.spacing(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
backdrop: {
|
||||||
|
zIndex: theme.zIndex.drawer + 1,
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ISnackProps {
|
||||||
|
severity: Color
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDetailButtonProps {
|
||||||
|
handleList?: () => void
|
||||||
|
handleSave?: () => void
|
||||||
|
saveMessages?: ISnackProps
|
||||||
|
}
|
||||||
|
|
||||||
|
const DetailButtons: React.FC<IDetailButtonProps> = ({
|
||||||
|
handleList,
|
||||||
|
handleSave,
|
||||||
|
saveMessages,
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { enqueueSnackbar } = useSnackbar()
|
||||||
|
|
||||||
|
const [isSuccessSnackBar, setSuccessSnackBar] = useRecoilState(
|
||||||
|
detailButtonsSnackAtom,
|
||||||
|
)
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSuccessSnackBar === 'success') {
|
||||||
|
enqueueSnackbar(saveMessages?.message || t('msg.success.save'), {
|
||||||
|
variant: saveMessages?.severity || 'success',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSuccessSnackBar !== 'loading') {
|
||||||
|
setAnchorEl(null)
|
||||||
|
}
|
||||||
|
}, [isSuccessSnackBar])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (anchorEl === null) {
|
||||||
|
setSuccessSnackBar('none')
|
||||||
|
}
|
||||||
|
}, [anchorEl])
|
||||||
|
|
||||||
|
const handlePopover = (target: HTMLButtonElement | null) => {
|
||||||
|
setAnchorEl(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box className={classes.container}>
|
||||||
|
{handleList && (
|
||||||
|
<Button variant="contained" onClick={handleList} color="default">
|
||||||
|
{t('label.button.list')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{handleSave && (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
handlePopover(event.currentTarget)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('label.button.save')}
|
||||||
|
</Button>
|
||||||
|
<ConfirmPopover
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
message={t('msg.confirm.save')}
|
||||||
|
handleConfirm={handleSave}
|
||||||
|
handlePopover={handlePopover}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DetailButtons }
|
||||||
85
frontend/admin/src/components/Buttons/GridButtons.tsx
Normal file
85
frontend/admin/src/components/Buttons/GridButtons.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import Box from '@material-ui/core/Box'
|
||||||
|
import { Button } from '@material-ui/core'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ConfirmPopover } from '@components/Confirm'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
display: 'flex',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface IGridButtonProps {
|
||||||
|
id: string
|
||||||
|
handleDelete?: (id: string | number) => void
|
||||||
|
handleUpdate?: (id: string | number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const GridButtons: React.FC<IGridButtonProps> = ({
|
||||||
|
id,
|
||||||
|
handleDelete,
|
||||||
|
handleUpdate,
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
const onClickUpdate = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
handleUpdate(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClickDelete = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
handleDelete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePopover = (target: HTMLButtonElement | null) => {
|
||||||
|
setAnchorEl(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
{handleUpdate && (
|
||||||
|
<Box mr={0.5}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={onClickUpdate}
|
||||||
|
>
|
||||||
|
{t('label.button.edit')}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{handleDelete && (
|
||||||
|
<Box ml={0.5}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
handlePopover(event.currentTarget)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('label.button.delete')}
|
||||||
|
</Button>
|
||||||
|
<ConfirmPopover
|
||||||
|
message={t('msg.confirm.delete')}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
handlePopover={handlePopover}
|
||||||
|
handleConfirm={onClickDelete}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { GridButtons }
|
||||||
3
frontend/admin/src/components/Buttons/index.tsx
Normal file
3
frontend/admin/src/components/Buttons/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './DetailButtons'
|
||||||
|
export * from './GridButtons'
|
||||||
|
export * from './CustomButtons'
|
||||||
50
frontend/admin/src/components/Confirm/ConfirmDialog.tsx
Normal file
50
frontend/admin/src/components/Confirm/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import Button from '@material-ui/core/Button'
|
||||||
|
import Dialog, { DialogProps } from '@material-ui/core/Dialog'
|
||||||
|
import DialogActions from '@material-ui/core/DialogActions'
|
||||||
|
import DialogContent from '@material-ui/core/DialogContent'
|
||||||
|
import DialogContentText from '@material-ui/core/DialogContentText'
|
||||||
|
import DialogTitle from '@material-ui/core/DialogTitle'
|
||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export interface ConfirmDialogProps extends DialogProps {
|
||||||
|
title?: string
|
||||||
|
contentText?: string
|
||||||
|
handleConfirm: () => void
|
||||||
|
handleClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmDialog = (props: ConfirmDialogProps) => {
|
||||||
|
const { open, handleClose, handleConfirm, title, contentText, ...rest } =
|
||||||
|
props
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
aria-labelledby="confirm-dialog-title"
|
||||||
|
aria-describedby="confirm-dialog-description"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{title && <DialogTitle id="confirm-dialog-title">{title}</DialogTitle>}
|
||||||
|
{contentText && (
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText id="confirm-dialog-description">
|
||||||
|
{contentText}
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
<DialogActions>
|
||||||
|
<Button variant="outlined" onClick={handleClose}>
|
||||||
|
{t('label.button.close')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" color="secondary" onClick={handleConfirm}>
|
||||||
|
{t('label.button.confirm')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ConfirmDialog }
|
||||||
67
frontend/admin/src/components/Confirm/ConfirmPopover.tsx
Normal file
67
frontend/admin/src/components/Confirm/ConfirmPopover.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Button from '@material-ui/core/Button'
|
||||||
|
import Card from '@material-ui/core/Card'
|
||||||
|
import CardActions from '@material-ui/core/CardActions'
|
||||||
|
import CardContent from '@material-ui/core/CardContent'
|
||||||
|
import Popover from '@material-ui/core/Popover'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
|
||||||
|
export interface ConfirmPopoverProps {
|
||||||
|
message: string
|
||||||
|
handleConfirm: (event?: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
|
handlePopover: (target: Element | null) => void
|
||||||
|
anchorEl: Element | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmPopover = ({
|
||||||
|
message,
|
||||||
|
handleConfirm,
|
||||||
|
handlePopover,
|
||||||
|
anchorEl,
|
||||||
|
}: ConfirmPopoverProps) => {
|
||||||
|
const open = Boolean(anchorEl)
|
||||||
|
const popId = open ? 'simple-popover' : undefined
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
id={popId}
|
||||||
|
open={open}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={() => {
|
||||||
|
handlePopover(null)
|
||||||
|
}}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'center',
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h5">{message}</Typography>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => {
|
||||||
|
handlePopover(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('label.button.close')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" color="secondary" onClick={handleConfirm}>
|
||||||
|
{t('label.button.confirm')}
|
||||||
|
</Button>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ConfirmPopover }
|
||||||
2
frontend/admin/src/components/Confirm/index.tsx
Normal file
2
frontend/admin/src/components/Confirm/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './ConfirmPopover'
|
||||||
|
export * from './ConfirmDialog'
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import DatePicker, { ReactDatePickerProps } from 'react-datepicker'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import TextField from '@material-ui/core/TextField'
|
||||||
|
import { Box } from '@material-ui/core'
|
||||||
|
import { Controller, ControllerProps } from 'react-hook-form'
|
||||||
|
import ValidationAlert from '@components/EditForm/ValidationAlert'
|
||||||
|
import { convertStringToDate, defaultlocales } from '@libs/date'
|
||||||
|
import { ControlledFieldProps } from '.'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
display: 'flex',
|
||||||
|
'& .react-datepicker-wrapper': {
|
||||||
|
width: 'fit-content',
|
||||||
|
},
|
||||||
|
'& .react-datepicker-popper': {
|
||||||
|
zIndex: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
margin: theme.spacing(2),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface CustomDataPickerProps extends Omit<ReactDatePickerProps, 'onChange'> {
|
||||||
|
name: any
|
||||||
|
label: string
|
||||||
|
contollerProps?: Omit<
|
||||||
|
ControllerProps,
|
||||||
|
'control' | 'name' | 'label' | 'render' | 'defaultValue'
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ControlledDateRangePickerProps extends ControlledFieldProps {
|
||||||
|
startProps: CustomDataPickerProps
|
||||||
|
endProps: CustomDataPickerProps
|
||||||
|
required?: boolean
|
||||||
|
format?: string
|
||||||
|
}
|
||||||
|
const ControlledDateRangePicker = (props: ControlledDateRangePickerProps) => {
|
||||||
|
const {
|
||||||
|
getValues,
|
||||||
|
control,
|
||||||
|
formState,
|
||||||
|
startProps,
|
||||||
|
endProps,
|
||||||
|
required = false,
|
||||||
|
format = 'yyyy-MM-dd',
|
||||||
|
} = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
const [startDate, setStartDate] = useState<Date | null>(null)
|
||||||
|
const [endDate, setEndDate] = useState<Date | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (getValues) {
|
||||||
|
if (getValues(startProps.name)) {
|
||||||
|
setStartDate(convertStringToDate(getValues(startProps.name)))
|
||||||
|
}
|
||||||
|
if (getValues(endProps.name)) {
|
||||||
|
setEndDate(convertStringToDate(getValues(endProps.name)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [props])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box className={classes.root}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={startProps.name}
|
||||||
|
defaultValue={''}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<DatePicker
|
||||||
|
customInput={
|
||||||
|
<TextField
|
||||||
|
id="outlined-basic"
|
||||||
|
label={startProps.label}
|
||||||
|
variant="outlined"
|
||||||
|
margin="dense"
|
||||||
|
required={required}
|
||||||
|
error={!!formState.errors[startProps.name]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
selected={startDate}
|
||||||
|
onChange={(
|
||||||
|
date: Date,
|
||||||
|
event: React.SyntheticEvent<any> | undefined,
|
||||||
|
) => {
|
||||||
|
setStartDate(date)
|
||||||
|
field.onChange(date)
|
||||||
|
}}
|
||||||
|
selectsStart
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
dateFormat={format}
|
||||||
|
locale={defaultlocales[i18n.language]}
|
||||||
|
{...startProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{...startProps.contollerProps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className={classes.to}>~</span>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={endProps.name}
|
||||||
|
defaultValue={''}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<DatePicker
|
||||||
|
customInput={
|
||||||
|
<TextField
|
||||||
|
id="outlined-basic"
|
||||||
|
label={endProps.label}
|
||||||
|
variant="outlined"
|
||||||
|
margin="dense"
|
||||||
|
required={required}
|
||||||
|
error={!!formState.errors[endProps.name]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
selected={endDate}
|
||||||
|
onChange={(
|
||||||
|
date: Date,
|
||||||
|
event: React.SyntheticEvent<any> | undefined,
|
||||||
|
) => {
|
||||||
|
setEndDate(date)
|
||||||
|
field.onChange(date)
|
||||||
|
}}
|
||||||
|
selectsEnd
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
minDate={startDate}
|
||||||
|
dateFormat={format}
|
||||||
|
locale={defaultlocales[i18n.language]}
|
||||||
|
{...endProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{...endProps.contollerProps}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{formState.errors[startProps.name] && (
|
||||||
|
<ValidationAlert
|
||||||
|
fieldError={formState.errors[startProps.name]}
|
||||||
|
label={startProps.label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{formState.errors[endProps.name] && (
|
||||||
|
<ValidationAlert
|
||||||
|
fieldError={formState.errors[endProps.name]}
|
||||||
|
label={endProps.label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ControlledDateRangePicker }
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Controller } from 'react-hook-form'
|
||||||
|
import ValidationAlert from '@components/EditForm/ValidationAlert'
|
||||||
|
import RadioGroupField from '@components/RadioGroupField'
|
||||||
|
import { ControlledFieldProps } from '.'
|
||||||
|
|
||||||
|
interface ControlledRadioFieldProps extends ControlledFieldProps {
|
||||||
|
name: any
|
||||||
|
label: string
|
||||||
|
defaultValue: string
|
||||||
|
data: { idkey: string; namekey: string; data: any[] }
|
||||||
|
requried?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ControlledRadioField = (props: ControlledRadioFieldProps) => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
defaultValue,
|
||||||
|
requried = false,
|
||||||
|
data,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<RadioGroupField
|
||||||
|
label={label}
|
||||||
|
required={requried}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
data={data.data.map(value => {
|
||||||
|
return {
|
||||||
|
label: value[data.namekey],
|
||||||
|
value: value[data.idkey],
|
||||||
|
labelPlacement: 'end',
|
||||||
|
onChange: (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
checked: boolean,
|
||||||
|
) => {
|
||||||
|
field.onChange(event.target.value)
|
||||||
|
},
|
||||||
|
inputRef: field.ref,
|
||||||
|
checked: field.value === value[data.idkey] ? true : false,
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
rules={{ required: requried }}
|
||||||
|
/>
|
||||||
|
{formState.errors[name] && (
|
||||||
|
<ValidationAlert fieldError={formState.errors[name]} label={label} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ControlledRadioField }
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Controller, ControllerProps } from 'react-hook-form'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import Box from '@material-ui/core/Box'
|
||||||
|
import FormControlLabel, {
|
||||||
|
FormControlLabelProps,
|
||||||
|
} from '@material-ui/core/FormControlLabel'
|
||||||
|
import Switch from '@material-ui/core/Switch'
|
||||||
|
import ValidationAlert from '@components/EditForm/ValidationAlert'
|
||||||
|
import { ControlledFieldProps } from '.'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'start',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.23)',
|
||||||
|
borderRadius: theme.spacing(0.5),
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
margin: theme.spacing(1, 0),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ControlledSwitchFieldProps extends ControlledFieldProps {
|
||||||
|
label: string
|
||||||
|
name: any
|
||||||
|
contollerProps?: Omit<
|
||||||
|
ControllerProps,
|
||||||
|
'control' | 'name' | 'label' | 'render'
|
||||||
|
>
|
||||||
|
labelProps?: Omit<FormControlLabelProps, 'label' | 'control'>
|
||||||
|
}
|
||||||
|
const ControlledSwitchField = (props: ControlledSwitchFieldProps) => {
|
||||||
|
const { control, formState, label, name, contollerProps, labelProps } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={classes.root}>
|
||||||
|
<FormControlLabel
|
||||||
|
label={label}
|
||||||
|
labelPlacement="start"
|
||||||
|
control={
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field: { onChange, ref, value } }) => (
|
||||||
|
<Switch
|
||||||
|
inputProps={{ 'aria-label': 'secondary checkbox' }}
|
||||||
|
onChange={onChange}
|
||||||
|
inputRef={ref}
|
||||||
|
checked={value}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{...contollerProps}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{...labelProps}
|
||||||
|
/>
|
||||||
|
{formState.errors[name] && (
|
||||||
|
<ValidationAlert fieldError={formState.errors[name]} label={label} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ControlledSwitchField }
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Controller, ControllerProps } from 'react-hook-form'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import ValidationAlert from '@components/EditForm/ValidationAlert'
|
||||||
|
import TextField, { TextFieldProps } from '@material-ui/core/TextField'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import { ControlledFieldProps } from '.'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ControlledTextFieldProps extends ControlledFieldProps {
|
||||||
|
name: any
|
||||||
|
label: string
|
||||||
|
defaultValue: string | number
|
||||||
|
isSelect?: boolean
|
||||||
|
children?: React.ReactNode
|
||||||
|
textFieldProps?: TextFieldProps
|
||||||
|
help?: string | React.ReactNode
|
||||||
|
contollerProps?: Omit<
|
||||||
|
ControllerProps,
|
||||||
|
'control' | 'name' | 'label' | 'render' | 'defaultValue'
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ControlledTextField = (props: ControlledTextFieldProps) => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
defaultValue,
|
||||||
|
isSelect = false,
|
||||||
|
children,
|
||||||
|
textFieldProps,
|
||||||
|
help,
|
||||||
|
contollerProps,
|
||||||
|
} = props
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<div className={classes.content}>
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
select={isSelect}
|
||||||
|
label={label}
|
||||||
|
required
|
||||||
|
variant="outlined"
|
||||||
|
margin="dense"
|
||||||
|
inputRef={field.ref}
|
||||||
|
value={field.value}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
{...field}
|
||||||
|
{...textFieldProps}
|
||||||
|
>
|
||||||
|
{isSelect && children}
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
rules={{ required: true, maxLength: 100 }}
|
||||||
|
{...contollerProps}
|
||||||
|
/>
|
||||||
|
{help && help}
|
||||||
|
</div>
|
||||||
|
{formState.errors[name] && (
|
||||||
|
<ValidationAlert
|
||||||
|
fieldError={formState.errors[name]}
|
||||||
|
target={[100]}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ControlledTextField }
|
||||||
12
frontend/admin/src/components/ControlledField/index.tsx
Normal file
12
frontend/admin/src/components/ControlledField/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Control, FormState, UseFormGetValues } from 'react-hook-form'
|
||||||
|
|
||||||
|
export * from './ControlledDateRangePicker'
|
||||||
|
export * from './ControlledRadioField'
|
||||||
|
export * from './ControlledSwitchField'
|
||||||
|
export * from './ControlledTextField'
|
||||||
|
|
||||||
|
export interface ControlledFieldProps {
|
||||||
|
control: Control<any, object>
|
||||||
|
formState: FormState<any>
|
||||||
|
getValues?: UseFormGetValues<any>
|
||||||
|
}
|
||||||
19
frontend/admin/src/components/Copyright.tsx
Normal file
19
frontend/admin/src/components/Copyright.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Typography } from '@material-ui/core'
|
||||||
|
import Link from '@material-ui/core/Link'
|
||||||
|
import { getCurrentDate } from '@libs/date'
|
||||||
|
|
||||||
|
const Copyright = () => {
|
||||||
|
return (
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
{'Copyright © '}
|
||||||
|
<Link color="inherit" href="https://material-ui.com/">
|
||||||
|
Your Website
|
||||||
|
</Link>{' '}
|
||||||
|
{getCurrentDate().getFullYear()}
|
||||||
|
{'.'}
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Copyright
|
||||||
110
frontend/admin/src/components/CustomAlert/index.tsx
Normal file
110
frontend/admin/src/components/CustomAlert/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import Button, { ButtonProps } from '@material-ui/core/Button'
|
||||||
|
import Dialog, { DialogProps } from '@material-ui/core/Dialog'
|
||||||
|
import DialogActions from '@material-ui/core/DialogActions'
|
||||||
|
import DialogContent from '@material-ui/core/DialogContent'
|
||||||
|
import DialogContentText from '@material-ui/core/DialogContentText'
|
||||||
|
import DialogTitle from '@material-ui/core/DialogTitle'
|
||||||
|
import { Color } from '@material-ui/lab/Alert'
|
||||||
|
import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'
|
||||||
|
import ErrorOutlineOutlinedIcon from '@material-ui/icons/ErrorOutlineOutlined'
|
||||||
|
import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined'
|
||||||
|
import CheckCircleOutlineOutlinedIcon from '@material-ui/icons/CheckCircleOutlineOutlined'
|
||||||
|
import { Typography } from '@material-ui/core'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
icon: {
|
||||||
|
position: 'relative',
|
||||||
|
top: '0.11em',
|
||||||
|
width: theme.typography.h5.fontSize,
|
||||||
|
height: theme.typography.h5.fontSize,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface CustomAlertPrpps extends DialogProps {
|
||||||
|
title?: string
|
||||||
|
severity?: Color
|
||||||
|
contentText?: string | string[]
|
||||||
|
handleAlert: () => void
|
||||||
|
buttonText?: string
|
||||||
|
buttonProps?: ButtonProps
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomAlert = (props: CustomAlertPrpps) => {
|
||||||
|
const {
|
||||||
|
open,
|
||||||
|
handleAlert,
|
||||||
|
title,
|
||||||
|
severity,
|
||||||
|
contentText,
|
||||||
|
buttonText,
|
||||||
|
buttonProps,
|
||||||
|
...rest
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const icon = useCallback(() => {
|
||||||
|
return severity === 'error' ? (
|
||||||
|
<ErrorOutlineOutlinedIcon color="error" className={classes.icon} />
|
||||||
|
) : severity === 'success' ? (
|
||||||
|
<CheckCircleOutlineOutlinedIcon className={classes.icon} />
|
||||||
|
) : severity === 'warning' ? (
|
||||||
|
<ReportProblemOutlinedIcon className={classes.icon} />
|
||||||
|
) : (
|
||||||
|
<InfoOutlinedIcon className={classes.icon} />
|
||||||
|
)
|
||||||
|
}, [severity])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
aria-labelledby="alert-dialog-title"
|
||||||
|
aria-describedby="alert-dialog-description"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<DialogTitle id="alert-dialog-title" disableTypography={true}>
|
||||||
|
<Typography variant="h5">
|
||||||
|
{icon()} {title || t('common.noti')}
|
||||||
|
</Typography>
|
||||||
|
</DialogTitle>
|
||||||
|
{contentText && (
|
||||||
|
<DialogContent>
|
||||||
|
{Array.isArray(contentText) ? (
|
||||||
|
contentText.map((value, index) => (
|
||||||
|
<DialogContentText
|
||||||
|
key={`dialog-${index}`}
|
||||||
|
id={`alert-dialog-description-${index}`}
|
||||||
|
>
|
||||||
|
- {value}
|
||||||
|
</DialogContentText>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<DialogContentText id="alert-dialog-description">
|
||||||
|
{contentText}
|
||||||
|
</DialogContentText>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleAlert}
|
||||||
|
color="primary"
|
||||||
|
autoFocus
|
||||||
|
{...buttonProps}
|
||||||
|
>
|
||||||
|
{buttonText || t('label.button.confirm')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomAlert
|
||||||
84
frontend/admin/src/components/CustomBarChart/index.tsx
Normal file
84
frontend/admin/src/components/CustomBarChart/index.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart as ReBarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts'
|
||||||
|
import Paper from '@material-ui/core/Paper'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
|
||||||
|
const MAB_BAR_THICKNESS = 50
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
paper: {
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface BarChartProps {
|
||||||
|
data: any
|
||||||
|
id: string
|
||||||
|
tooltipContent?: ({ active, payload, label }) => React.ReactNode
|
||||||
|
title?: string
|
||||||
|
handleCellClick?: (data, index) => void
|
||||||
|
customxAxisTick?: (value: any, index: number) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomBarChart = ({
|
||||||
|
data,
|
||||||
|
id,
|
||||||
|
tooltipContent,
|
||||||
|
title,
|
||||||
|
handleCellClick,
|
||||||
|
customxAxisTick,
|
||||||
|
}: BarChartProps) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper variant="outlined" className={classes.paper}>
|
||||||
|
{title && (
|
||||||
|
<Typography className={classes.title} variant="h4">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<ResponsiveContainer width="90%" height={400}>
|
||||||
|
<ReBarChart
|
||||||
|
key={id}
|
||||||
|
data={data}
|
||||||
|
margin={{ top: 20, right: 30, left: 0, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="x" tickFormatter={customxAxisTick} />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip cursor={{ fill: 'transparent' }} content={tooltipContent} />
|
||||||
|
<Bar
|
||||||
|
dataKey="y"
|
||||||
|
maxBarSize={MAB_BAR_THICKNESS}
|
||||||
|
stroke="#8884d8"
|
||||||
|
fill="#8884d8"
|
||||||
|
type="monotone"
|
||||||
|
onClick={handleCellClick}
|
||||||
|
/>
|
||||||
|
</ReBarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomBarChart
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useTreeItemStyles } from '@components/DraggableTreeMenu/DraaggableTreeMenuItem'
|
||||||
|
import Checkbox from '@material-ui/core/Checkbox'
|
||||||
|
import Icon from '@material-ui/core/Icon'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
import TreeItem, { TreeItemProps } from '@material-ui/lab/TreeItem'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.action.hover,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
checked: {
|
||||||
|
padding: theme.spacing(0.5),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface CustomTreeItemProps extends TreeItemProps {
|
||||||
|
node: any
|
||||||
|
isChecked?: boolean
|
||||||
|
handleChecked?: (node: any, checked: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTreeItem = (props: CustomTreeItemProps) => {
|
||||||
|
const { node, isChecked, handleChecked, ...rest } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const itemClasses = useTreeItemStyles()
|
||||||
|
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
|
||||||
|
const [checked, setChecked] = useState<boolean>(node.isChecked)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (node) {
|
||||||
|
setChecked(node.isChecked)
|
||||||
|
}
|
||||||
|
}, [node])
|
||||||
|
|
||||||
|
const handleLabelClick = (event: React.MouseEvent<HTMLInputElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
handleChecked(node, !checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TreeItem
|
||||||
|
label={
|
||||||
|
<div
|
||||||
|
className={`${itemClasses.labelRoot} ${
|
||||||
|
checked ? itemClasses.selected : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isChecked && (
|
||||||
|
<Checkbox
|
||||||
|
className={classes.checked}
|
||||||
|
checked={checked}
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
inputProps={{ 'aria-label': 'primary checkbox' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Icon fontSize="small" className={itemClasses.labelIcon}>
|
||||||
|
{node.icon || 'folder'}
|
||||||
|
</Icon>
|
||||||
|
<Typography variant="body2" className={itemClasses.labelText}>
|
||||||
|
{i18n.language === 'ko' ? node.korName : node.engName}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onLabelClick={handleLabelClick}
|
||||||
|
classes={{
|
||||||
|
root: classes.root,
|
||||||
|
label: itemClasses.label,
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomTreeItem
|
||||||
187
frontend/admin/src/components/CustomTreeView/index.tsx
Normal file
187
frontend/admin/src/components/CustomTreeView/index.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import {
|
||||||
|
checkedChildren,
|
||||||
|
findAllIds,
|
||||||
|
findTreeItem,
|
||||||
|
treeChecked,
|
||||||
|
treeTargetChecked,
|
||||||
|
} from '@components/DraggableTreeMenu/TreeUtils'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown'
|
||||||
|
import ArrowRightIcon from '@material-ui/icons/ArrowRight'
|
||||||
|
import TreeView, { TreeViewProps } from '@material-ui/lab/TreeView'
|
||||||
|
import produce from 'immer'
|
||||||
|
import React, {
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import CustomTreeItem from './CustomTreeItem'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export type CustomTreeViewType = {
|
||||||
|
getTreeData: () => any[]
|
||||||
|
handleAllChecked: (checked: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomTreeViewProps {
|
||||||
|
data: any[]
|
||||||
|
isChecked?: boolean
|
||||||
|
isAllExpanded?: boolean
|
||||||
|
treeViewProps?: TreeViewProps
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderTree = (
|
||||||
|
nodes: any,
|
||||||
|
isChecked: boolean,
|
||||||
|
handleChecked: (node: object, checked: boolean) => void,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<CustomTreeItem
|
||||||
|
key={`item-${nodes.id}`}
|
||||||
|
nodeId={`${nodes.id}`}
|
||||||
|
node={nodes}
|
||||||
|
isChecked={isChecked}
|
||||||
|
handleChecked={handleChecked}
|
||||||
|
>
|
||||||
|
{Array.isArray(nodes.children)
|
||||||
|
? nodes.children.map((node, idx) =>
|
||||||
|
renderTree(node, isChecked, handleChecked),
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</CustomTreeItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTreeView = forwardRef<CustomTreeViewType, CustomTreeViewProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const { data, isChecked = false, isAllExpanded, treeViewProps } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState<string[]>(null)
|
||||||
|
const [tree, setTree] = useState(data)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
setTree(data)
|
||||||
|
}
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAllExpanded) {
|
||||||
|
const ids: string[] = findAllIds(tree)
|
||||||
|
setExpanded(ids)
|
||||||
|
} else {
|
||||||
|
setExpanded([])
|
||||||
|
}
|
||||||
|
}, [isAllExpanded])
|
||||||
|
|
||||||
|
const handleNodeToggle = (event: object, nodeIds: []) => {
|
||||||
|
setExpanded(nodeIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChecked = (node: any, checked: boolean) => {
|
||||||
|
// 해당 노드와 자식노드들 chekced 상태 변경
|
||||||
|
const updateTree = (item: any, isChild: boolean = false) => {
|
||||||
|
return produce(item, draft => {
|
||||||
|
if (isChild || `${item.id}` === `${node.id}`) {
|
||||||
|
draft.isChecked = checked
|
||||||
|
|
||||||
|
if (draft.children) {
|
||||||
|
const arr = Array.from(draft.children)
|
||||||
|
draft.children = arr.map(i => {
|
||||||
|
return updateTree(i, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (draft.children) {
|
||||||
|
const arr = Array.from(draft.children)
|
||||||
|
draft.children = arr.map(i => {
|
||||||
|
return updateTree(i, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let newTree = tree.map(item => {
|
||||||
|
return updateTree(item)
|
||||||
|
}) as any[]
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
//checked = true 이면 부모 node checked
|
||||||
|
let findItem = { ...node }
|
||||||
|
while (true) {
|
||||||
|
const find = findTreeItem(newTree, findItem.id, 'id')
|
||||||
|
|
||||||
|
if (!find.parent) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
newTree = newTree.map(item => {
|
||||||
|
return treeTargetChecked(item, find.parent, checked)
|
||||||
|
}) as any[]
|
||||||
|
|
||||||
|
findItem = { ...find.parent }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
//checked = false 이면 level==1 인 부모 node는 자식 node 들 중 체크가 하나라도 있으면 그냥 넘어가고 하나도 없으면 체크 해제
|
||||||
|
let findItem = { ...node }
|
||||||
|
if (findItem.level > 1) {
|
||||||
|
let level = 0
|
||||||
|
while (level !== 1) {
|
||||||
|
const find = findTreeItem(newTree, findItem.id, 'id')
|
||||||
|
|
||||||
|
level = find.parent.level
|
||||||
|
findItem = { ...find.parent }
|
||||||
|
}
|
||||||
|
|
||||||
|
const childrenCheck = checkedChildren(findItem)
|
||||||
|
if (!childrenCheck) {
|
||||||
|
newTree = newTree.map(item => {
|
||||||
|
return treeTargetChecked(item, findItem, false)
|
||||||
|
}) as any[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTree(newTree)
|
||||||
|
}
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
getTreeData: () => {
|
||||||
|
return tree
|
||||||
|
},
|
||||||
|
handleAllChecked: (checked: boolean) => {
|
||||||
|
const newTree = tree.map(item => {
|
||||||
|
return treeChecked(item, checked)
|
||||||
|
})
|
||||||
|
|
||||||
|
setTree(newTree)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TreeView
|
||||||
|
className={classes.root}
|
||||||
|
defaultCollapseIcon={<ArrowDropDownIcon />}
|
||||||
|
defaultExpandIcon={<ArrowRightIcon />}
|
||||||
|
defaultEndIcon={<div style={{ width: 24 }} />}
|
||||||
|
expanded={expanded}
|
||||||
|
onNodeToggle={handleNodeToggle}
|
||||||
|
{...treeViewProps}
|
||||||
|
>
|
||||||
|
{tree && tree.map(item => renderTree(item, isChecked, handleChecked))}
|
||||||
|
</TreeView>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export default CustomTreeView
|
||||||
74
frontend/admin/src/components/DialogPopup/index.tsx
Normal file
74
frontend/admin/src/components/DialogPopup/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Typography } from '@material-ui/core'
|
||||||
|
import Dialog, { DialogProps } from '@material-ui/core/Dialog'
|
||||||
|
import DialogActions, {
|
||||||
|
DialogActionsProps,
|
||||||
|
} from '@material-ui/core/DialogActions'
|
||||||
|
import DialogContent from '@material-ui/core/DialogContent'
|
||||||
|
import DialogTitle from '@material-ui/core/DialogTitle'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import CloseIcon from '@material-ui/icons/Close'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
close: {
|
||||||
|
position: 'absolute',
|
||||||
|
color: theme.palette.grey[500],
|
||||||
|
right: theme.spacing(1),
|
||||||
|
top: theme.spacing(1),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존의 페이지를 팝업창으로 호출할 경우 사용
|
||||||
|
*/
|
||||||
|
export interface PopupProps {
|
||||||
|
handlePopup?: (data: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogPopupProps extends DialogProps {
|
||||||
|
id: string
|
||||||
|
children: React.ReactNode
|
||||||
|
handleClose: () => void
|
||||||
|
title?: string
|
||||||
|
action?: {
|
||||||
|
props: DialogActionsProps
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogPopup = (props: DialogPopupProps) => {
|
||||||
|
const { id, children, handleClose, title, action, ...rest } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
aria-labelledby={id}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="md"
|
||||||
|
onClose={handleClose}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<DialogTitle disableTypography id="dialog-title">
|
||||||
|
<Typography variant="h3"> {title || 'Popup'}</Typography>
|
||||||
|
{handleClose && (
|
||||||
|
<IconButton
|
||||||
|
className={classes.close}
|
||||||
|
aria-label="close"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>{children}</DialogContent>
|
||||||
|
{action && (
|
||||||
|
<DialogActions {...action.props}>{action.children}</DialogActions>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DialogPopup
|
||||||
55
frontend/admin/src/components/DisableTextField/index.tsx
Normal file
55
frontend/admin/src/components/DisableTextField/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import Grid, { GridProps } from '@material-ui/core/Grid'
|
||||||
|
import Paper from '@material-ui/core/Paper'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
label: {
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
textAlign: 'center',
|
||||||
|
backgroundColor: theme.palette.background.default,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
textAlign: 'left',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface DisableTextFieldProps {
|
||||||
|
label: string
|
||||||
|
value: string | number | React.ReactNode
|
||||||
|
labelProps?: GridProps
|
||||||
|
valueProps?: GridProps
|
||||||
|
}
|
||||||
|
|
||||||
|
const DisableTextField = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
labelProps,
|
||||||
|
valueProps,
|
||||||
|
}: DisableTextFieldProps) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
return (
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={4} {...labelProps}>
|
||||||
|
<Paper className={classes.label}>
|
||||||
|
<Typography variant="body1">{label}</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={8} {...valueProps}>
|
||||||
|
<Paper className={classes.text}>
|
||||||
|
{typeof value === 'string' || typeof value === 'number' ? (
|
||||||
|
<Typography variant="body1">{value}</Typography>
|
||||||
|
) : (
|
||||||
|
value
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DisableTextField
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { ItemId, TreeItem } from '@atlaskit/tree'
|
||||||
|
import { IMenuTree } from '@service'
|
||||||
|
import produce from 'immer'
|
||||||
|
|
||||||
|
export type TreeItemType = TreeItem & {
|
||||||
|
parentId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class TreeBuilder {
|
||||||
|
rootId: ItemId
|
||||||
|
|
||||||
|
items: Record<ItemId, TreeItemType>
|
||||||
|
|
||||||
|
constructor(rootId: ItemId, data?: IMenuTree) {
|
||||||
|
const rootItem = this._createItem(`${rootId}`, data)
|
||||||
|
this.rootId = rootItem.id
|
||||||
|
this.items = {
|
||||||
|
[rootItem.id]: rootItem,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
withLeaf(item: IMenuTree) {
|
||||||
|
const leafItem = this._createItem(`${this.rootId}-${item.menuId}`, item)
|
||||||
|
this._addItemToRoot(leafItem.id)
|
||||||
|
this.items[leafItem.id] = leafItem
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
withSubTree(tree: TreeBuilder) {
|
||||||
|
const subTree = tree.build()
|
||||||
|
this._addItemToRoot(`${this.rootId}-${subTree.rootId}`)
|
||||||
|
|
||||||
|
Object.keys(subTree.items).forEach(itemId => {
|
||||||
|
const finalId = `${this.rootId}-${itemId}`
|
||||||
|
this.items[finalId] = {
|
||||||
|
...subTree.items[itemId],
|
||||||
|
id: finalId,
|
||||||
|
children: subTree.items[itemId].children.map(
|
||||||
|
i => `${this.rootId}-${i}`,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
return {
|
||||||
|
rootId: this.rootId,
|
||||||
|
items: this.items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_addItemToRoot(id: string) {
|
||||||
|
const rootItem = this.items[this.rootId]
|
||||||
|
rootItem.children.push(id)
|
||||||
|
rootItem.isExpanded = true
|
||||||
|
rootItem.hasChildren = true
|
||||||
|
}
|
||||||
|
|
||||||
|
_createItem = (id: string, data?: IMenuTree) => {
|
||||||
|
data = produce(data, draft => {
|
||||||
|
if (draft) {
|
||||||
|
draft.children = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${id}`,
|
||||||
|
children: [],
|
||||||
|
hasChildren: false,
|
||||||
|
isExpanded: false,
|
||||||
|
isChildrenLoading: false,
|
||||||
|
data,
|
||||||
|
parentId: data?.parentId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import React, { createRef, useEffect, useState } from 'react'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import { ItemId, TreeItem } from '@atlaskit/tree'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown'
|
||||||
|
import ArrowRightIcon from '@material-ui/icons/ArrowRight'
|
||||||
|
import Icon from '@material-ui/core/Icon'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
import { useRecoilState } from 'recoil'
|
||||||
|
import { treeChangeNameAtom, draggableTreeSelectedAtom } from '@stores'
|
||||||
|
import TextField from '@material-ui/core/TextField'
|
||||||
|
import { ClassNameMap } from '@material-ui/styles'
|
||||||
|
|
||||||
|
export const useTreeItemStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
labelRoot: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: theme.spacing(0.5, 0),
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
display: 'flex',
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
color: '#1a73e8',
|
||||||
|
},
|
||||||
|
labelIcon: {
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
},
|
||||||
|
labelText: {
|
||||||
|
fontWeight: 'inherit',
|
||||||
|
flexGrow: 1,
|
||||||
|
textAlign: 'initial',
|
||||||
|
},
|
||||||
|
bull: {
|
||||||
|
width: '1em',
|
||||||
|
height: '1em',
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
paddingLeft: theme.spacing(1),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const getIcon = (
|
||||||
|
item: TreeItem,
|
||||||
|
onExpand: (itemId: ItemId) => void,
|
||||||
|
onCollapse: (itemId: ItemId) => void,
|
||||||
|
classes: ClassNameMap,
|
||||||
|
) => {
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
return item.isExpanded ? (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => onCollapse(item.id)}
|
||||||
|
aria-label="collapse"
|
||||||
|
>
|
||||||
|
<ArrowDropDownIcon />
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => onExpand(item.id)}
|
||||||
|
aria-label="expand"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton className={classes.bull} size="small">
|
||||||
|
•
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setLabel = (
|
||||||
|
item: TreeItem,
|
||||||
|
classes: ClassNameMap,
|
||||||
|
handleClick: (event: React.MouseEvent<HTMLDivElement>) => void,
|
||||||
|
handleKeyPress: (event: React.KeyboardEvent<HTMLInputElement>) => void,
|
||||||
|
handleBlur: () => void,
|
||||||
|
changed?: boolean,
|
||||||
|
selected?: boolean,
|
||||||
|
inputRef?: React.RefObject<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
if (changed) {
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
id="tree-name"
|
||||||
|
inputRef={inputRef}
|
||||||
|
margin="dense"
|
||||||
|
defaultValue={item.data?.name}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`${classes.label} ${selected ? classes.selected : ''}`}
|
||||||
|
>
|
||||||
|
<Icon fontSize="small" className={classes.labelIcon}>
|
||||||
|
{item.data?.icon || 'folder'}
|
||||||
|
</Icon>
|
||||||
|
<Typography variant="body2" className={classes.labelText}>
|
||||||
|
{item.data ? item.data.name : ''}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraaggableTreeMenuItemProps {
|
||||||
|
item: TreeItem
|
||||||
|
onExpand: (itemId: ItemId) => void
|
||||||
|
onCollapse: (itemId: ItemId) => void
|
||||||
|
selected?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DraaggableTreeMenuItem = (props: DraaggableTreeMenuItemProps) => {
|
||||||
|
const classes = useTreeItemStyles()
|
||||||
|
const { item, onExpand, onCollapse, selected } = props
|
||||||
|
|
||||||
|
const [treeSelected, setTreeSelected] = useRecoilState(
|
||||||
|
draggableTreeSelectedAtom,
|
||||||
|
)
|
||||||
|
const [treeChangeName, setTreeChangeName] = useRecoilState(treeChangeNameAtom)
|
||||||
|
const [changed, setChanged] = useState<boolean>(false)
|
||||||
|
const nameRef = createRef<HTMLInputElement>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
treeChangeName.state === 'change' &&
|
||||||
|
item.data?.menuId === treeSelected?.menuId
|
||||||
|
) {
|
||||||
|
setChanged(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setChanged(false)
|
||||||
|
}, [treeSelected, item, treeChangeName])
|
||||||
|
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setTreeSelected(item.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyPress = e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
setTreeChangeName({
|
||||||
|
state: 'complete',
|
||||||
|
id: item.data?.menuId,
|
||||||
|
name: nameRef.current?.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setTreeChangeName({
|
||||||
|
state: 'none',
|
||||||
|
id: null,
|
||||||
|
name: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${classes.labelRoot}`}>
|
||||||
|
{getIcon(item, onExpand, onCollapse, classes)}
|
||||||
|
{setLabel(
|
||||||
|
item,
|
||||||
|
classes,
|
||||||
|
handleClick,
|
||||||
|
handleKeyPress,
|
||||||
|
handleBlur,
|
||||||
|
changed,
|
||||||
|
selected,
|
||||||
|
nameRef,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DraaggableTreeMenuItem
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Button from '@material-ui/core/Button'
|
||||||
|
import ButtonGroup from '@material-ui/core/ButtonGroup'
|
||||||
|
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'
|
||||||
|
import ExpandLessOutlinedIcon from '@material-ui/icons/ExpandLessOutlined'
|
||||||
|
import ExpandMoreOutlinedIcon from '@material-ui/icons/ExpandMoreOutlined'
|
||||||
|
import CheckBoxOutlineBlankOutlinedIcon from '@material-ui/icons/CheckBoxOutlineBlankOutlined'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
'& .MuiButton-containedSizeSmall': {
|
||||||
|
padding: '4px 6px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
},
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface TreeBelowButtonsProps {
|
||||||
|
handleExpand: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
|
handleCollapse: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
|
handleDeselect?: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TreeSubButtons = (props: TreeBelowButtonsProps) => {
|
||||||
|
const { handleExpand, handleCollapse, handleDeselect } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ButtonGroup
|
||||||
|
className={classes.root}
|
||||||
|
size="small"
|
||||||
|
aria-label="menu tree buttons"
|
||||||
|
variant="contained"
|
||||||
|
>
|
||||||
|
<Button onClick={handleExpand}>
|
||||||
|
<ExpandMoreOutlinedIcon fontSize="small" />
|
||||||
|
{t('menu.all_expand')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCollapse}>
|
||||||
|
<ExpandLessOutlinedIcon fontSize="small" />
|
||||||
|
{t('menu.all_collapse')}
|
||||||
|
</Button>
|
||||||
|
{handleDeselect && (
|
||||||
|
<Button onClick={handleDeselect}>
|
||||||
|
<CheckBoxOutlineBlankOutlinedIcon fontSize="small" />
|
||||||
|
{t('label.button.deselect')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ButtonGroup>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TreeSubButtons
|
||||||
249
frontend/admin/src/components/DraggableTreeMenu/TreeUtils.ts
Normal file
249
frontend/admin/src/components/DraggableTreeMenu/TreeUtils.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { TreeData, TreeItem } from '@atlaskit/tree'
|
||||||
|
import { IMenuTree } from '@service'
|
||||||
|
import produce from 'immer'
|
||||||
|
import DraggableTreeBuilder from './DraaggableTreeBuilder'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hierarchy json data -> atlaskit flat tree data
|
||||||
|
*
|
||||||
|
* @param data
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const convertJsonToTreeData = (data: IMenuTree[]) => {
|
||||||
|
const createTree = (item: any, builder: DraggableTreeBuilder) => {
|
||||||
|
if (item.children) {
|
||||||
|
let sub = new DraggableTreeBuilder(item.menuId, item)
|
||||||
|
item.children.map(i => createTree(i, sub))
|
||||||
|
builder.withSubTree(sub)
|
||||||
|
} else {
|
||||||
|
builder.withLeaf(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = new DraggableTreeBuilder(0)
|
||||||
|
data?.map(item => {
|
||||||
|
createTree(item, root)
|
||||||
|
})
|
||||||
|
|
||||||
|
return root.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* atlaskit flat tree data -> hierarchy json data
|
||||||
|
*
|
||||||
|
* @param tree
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const convertTreeDataToJson = (tree: TreeData) => {
|
||||||
|
let newTreeItem: TreeItem[] = []
|
||||||
|
|
||||||
|
const arrTree = Object.values(tree.items)
|
||||||
|
const root = arrTree.shift()
|
||||||
|
|
||||||
|
root.children.map((itemId, index) => {
|
||||||
|
const data = arrTree.splice(
|
||||||
|
arrTree.findIndex(item => item.id === itemId),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
data.map(item => {
|
||||||
|
item.data = produce(item.data, draft => {
|
||||||
|
draft.sortSeq = index + 1
|
||||||
|
draft.parentId = null
|
||||||
|
})
|
||||||
|
newTreeItem.push(item)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const convert = (target: TreeItem[], source: TreeItem[]) => {
|
||||||
|
while (source.length > 0) {
|
||||||
|
const data = source.shift()
|
||||||
|
|
||||||
|
if (data.hasChildren) {
|
||||||
|
target.push(
|
||||||
|
produce(data, draft => {
|
||||||
|
draft.hasChildren = false
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = target.findIndex(item => item.children.includes(data.id))
|
||||||
|
|
||||||
|
if (idx > -1) {
|
||||||
|
const parent = produce(target[idx].data as IMenuTree, draft => {
|
||||||
|
const childIdx = draft.children.findIndex(
|
||||||
|
i => i.menuId === data.data.menuId,
|
||||||
|
)
|
||||||
|
|
||||||
|
const child = produce(data.data as IMenuTree, childDraft => {
|
||||||
|
if (childIdx === -1) {
|
||||||
|
childDraft.sortSeq = draft.children.length + 1
|
||||||
|
}
|
||||||
|
childDraft.parentId = draft.menuId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (childIdx > -1) {
|
||||||
|
draft.children[childIdx] = child
|
||||||
|
} else {
|
||||||
|
draft.children.push(child)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
target[idx] = produce(target[idx], draft => {
|
||||||
|
draft.data = parent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = newTreeItem.slice()
|
||||||
|
let source = arrTree.slice()
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
newTreeItem = convert(target, source).slice()
|
||||||
|
|
||||||
|
if (root.children.length === newTreeItem.length) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
target = newTreeItem.filter(item => root.children.includes(item.id))
|
||||||
|
source = newTreeItem.filter(item => !root.children.includes(item.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const newData: IMenuTree[] = []
|
||||||
|
newTreeItem.map(treeItem => {
|
||||||
|
newData.push(Object.assign(treeItem.data))
|
||||||
|
})
|
||||||
|
|
||||||
|
return newData
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFindTree {
|
||||||
|
item: any
|
||||||
|
parent: any
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hierarchy json data에서 조건에 맞는 데이터 찾기
|
||||||
|
*
|
||||||
|
* @param arr 원본 json array (any[])
|
||||||
|
* @param value 찾고자 하는 데이터 (number or string)
|
||||||
|
* @param key object인 경우 데이터 key (string)
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const findTreeItem = (
|
||||||
|
arr: any[],
|
||||||
|
value: number | string,
|
||||||
|
key?: string,
|
||||||
|
): IFindTree => {
|
||||||
|
let target
|
||||||
|
let parent
|
||||||
|
let findKey = key || 'index'
|
||||||
|
const findAllItems = (item: any, parentItem?: any) => {
|
||||||
|
if (item[findKey] === value) {
|
||||||
|
target = item
|
||||||
|
parent = parentItem
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.children) {
|
||||||
|
item.children.map((v, k) => {
|
||||||
|
return findAllItems(v, item)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arr.map(item => {
|
||||||
|
findAllItems(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: target,
|
||||||
|
parent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hierarchy json data에서 모든 id값 찾기
|
||||||
|
*
|
||||||
|
* @param arr 원본 json array (any[])
|
||||||
|
* @param findKey id의 key 값 = default 'id' (string)
|
||||||
|
* @returns string[]
|
||||||
|
*/
|
||||||
|
export const findAllIds = (arr: any[], findKey?: string): string[] => {
|
||||||
|
const ids = []
|
||||||
|
const key = findKey || 'id'
|
||||||
|
|
||||||
|
const findAll = (item: any) => {
|
||||||
|
ids.push(`${item[key]}`)
|
||||||
|
|
||||||
|
if (item.children) {
|
||||||
|
item.children.map(i => findAll(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arr.map(item => {
|
||||||
|
findAll(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hierarchy json data에서 해당하는 데이터 checked or unchecked
|
||||||
|
*
|
||||||
|
* @param node
|
||||||
|
* @param target
|
||||||
|
* @param checked
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const treeTargetChecked = (node: any, target: any, checked: boolean) => {
|
||||||
|
return produce(node, draft => {
|
||||||
|
if (`${draft.id}` === `${target.id}`) {
|
||||||
|
draft.isChecked = checked
|
||||||
|
} else {
|
||||||
|
if (draft.children) {
|
||||||
|
const arr = Array.from(draft.children)
|
||||||
|
draft.children = arr.map(i => {
|
||||||
|
return treeTargetChecked(i, target, checked)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 해당 노드 자식 데이터 모두 checked
|
||||||
|
*
|
||||||
|
* @param node
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const checkedChildren = (node: any) => {
|
||||||
|
for (const iterator of node.children) {
|
||||||
|
if (iterator.isChecked) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tree data all checked or unchecked
|
||||||
|
*
|
||||||
|
* @param node
|
||||||
|
* @param checked
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const treeChecked = (node: any, checked: boolean) => {
|
||||||
|
return produce(node, draft => {
|
||||||
|
draft.isChecked = checked
|
||||||
|
if (draft.children) {
|
||||||
|
const arr = Array.from(draft.children)
|
||||||
|
draft.children = arr.map(i => {
|
||||||
|
return treeChecked(i, checked)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
153
frontend/admin/src/components/DraggableTreeMenu/index.tsx
Normal file
153
frontend/admin/src/components/DraggableTreeMenu/index.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import Tree, {
|
||||||
|
ItemId,
|
||||||
|
moveItemOnTree,
|
||||||
|
mutateTree,
|
||||||
|
RenderItemParams,
|
||||||
|
TreeData,
|
||||||
|
TreeDestinationPosition,
|
||||||
|
TreeSourcePosition,
|
||||||
|
} from '@atlaskit/tree'
|
||||||
|
import { convertTreeDataToJson, convertJsonToTreeData } from './TreeUtils'
|
||||||
|
import { IMenuTree } from '@service'
|
||||||
|
import DraaggableTreeMenuItem from './DraaggableTreeMenuItem'
|
||||||
|
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||||
|
import { draggableTreeExpandedAtom, draggableTreeSelectedAtom } from '@stores'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.action.hover,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
backgroundColor: `#e8f0fe`,
|
||||||
|
color: '#1a73e8',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const PADDING_PER_LEVEL = 40
|
||||||
|
|
||||||
|
export interface DraggableTreeMenuProps {
|
||||||
|
data: IMenuTree[]
|
||||||
|
handleTreeDnD: (tree: IMenuTree[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableTreeMenu(props: DraggableTreeMenuProps) {
|
||||||
|
const classes = useStyles()
|
||||||
|
const { data, handleTreeDnD } = props
|
||||||
|
|
||||||
|
const treeSelected = useRecoilValue(draggableTreeSelectedAtom)
|
||||||
|
const [treeExpanded, setTreeExpanded] = useRecoilState(
|
||||||
|
draggableTreeExpandedAtom,
|
||||||
|
)
|
||||||
|
|
||||||
|
const [tree, setTree] = useState<TreeData>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTreeExpanded('collapse')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
setTree(convertJsonToTreeData(data))
|
||||||
|
}
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (treeExpanded === 'none') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tree) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const expanded = treeExpanded === 'expand'
|
||||||
|
|
||||||
|
let treeData = tree
|
||||||
|
for (const key in tree.items) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(tree.items, key)) {
|
||||||
|
treeData = mutateTree(treeData, key, { isExpanded: expanded })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTree(treeData)
|
||||||
|
}, [treeExpanded])
|
||||||
|
|
||||||
|
const renderItem = ({
|
||||||
|
item,
|
||||||
|
onExpand,
|
||||||
|
onCollapse,
|
||||||
|
provided,
|
||||||
|
}: RenderItemParams) => {
|
||||||
|
const selected: boolean = item.data?.menuId === treeSelected?.menuId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${classes.item} ${selected ? classes.selected : ''}`}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
>
|
||||||
|
<DraaggableTreeMenuItem
|
||||||
|
item={item}
|
||||||
|
onExpand={onExpand}
|
||||||
|
onCollapse={onCollapse}
|
||||||
|
selected={selected}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onExpand = (itemId: ItemId) => {
|
||||||
|
setTreeExpanded('none')
|
||||||
|
setTree(mutateTree(tree, itemId, { isExpanded: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCollapse = (itemId: ItemId) => {
|
||||||
|
setTreeExpanded('none')
|
||||||
|
setTree(mutateTree(tree, itemId, { isExpanded: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragEnd = async (
|
||||||
|
source: TreeSourcePosition,
|
||||||
|
destination?: TreeDestinationPosition,
|
||||||
|
) => {
|
||||||
|
if (!destination) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTree = moveItemOnTree(tree, source, destination)
|
||||||
|
|
||||||
|
const convert = await convertTreeDataToJson(newTree)
|
||||||
|
handleTreeDnD(convert)
|
||||||
|
|
||||||
|
setTree(newTree)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
{tree && (
|
||||||
|
<Tree
|
||||||
|
tree={tree}
|
||||||
|
renderItem={renderItem}
|
||||||
|
onExpand={onExpand}
|
||||||
|
onCollapse={onCollapse}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
offsetPerLevel={PADDING_PER_LEVEL}
|
||||||
|
isDragEnabled
|
||||||
|
isNestingEnabled
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DraggableTreeMenu
|
||||||
539
frontend/admin/src/components/EditForm/MenuEditForm.tsx
Normal file
539
frontend/admin/src/components/EditForm/MenuEditForm.tsx
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
import { DetailButtons } from '@components/Buttons'
|
||||||
|
import DialogPopup from '@components/DialogPopup'
|
||||||
|
import Button from '@material-ui/core/Button'
|
||||||
|
import Card from '@material-ui/core/Card'
|
||||||
|
import CardContent from '@material-ui/core/CardContent'
|
||||||
|
import CardHeader from '@material-ui/core/CardHeader'
|
||||||
|
import Divider from '@material-ui/core/Divider'
|
||||||
|
import FormControlLabel from '@material-ui/core/FormControlLabel'
|
||||||
|
import FormGroup from '@material-ui/core/FormGroup'
|
||||||
|
import FormHelperText from '@material-ui/core/FormHelperText'
|
||||||
|
import Paper from '@material-ui/core/Paper'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import Switch from '@material-ui/core/Switch'
|
||||||
|
import TextField from '@material-ui/core/TextField'
|
||||||
|
import ToggleButton from '@material-ui/lab/ToggleButton'
|
||||||
|
import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup'
|
||||||
|
import Board from '@pages/board'
|
||||||
|
import Content from '@pages/content'
|
||||||
|
import { MenuFormContext } from '@pages/menu'
|
||||||
|
import { ICode, IMenuInfoForm } from '@service'
|
||||||
|
import produce from 'immer'
|
||||||
|
import React, { useContext, useEffect, useState } from 'react'
|
||||||
|
import { Controller, FormProvider, useForm } from 'react-hook-form'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import ValidationAlert from './ValidationAlert'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: theme.spacing(1, 2),
|
||||||
|
},
|
||||||
|
buttons: {
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
'& .MuiToggleButtonGroup-grouped': {
|
||||||
|
lineHeight: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
searchButton: {
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
boxShadow: theme.shadows[0],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
verticalDivider: {
|
||||||
|
height: 28,
|
||||||
|
margin: 4,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface MenuEditFormProps {
|
||||||
|
handleSave: (formData: IMenuInfoForm) => void
|
||||||
|
menuTypes?: ICode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IConnectId {
|
||||||
|
code?: number
|
||||||
|
name?: string
|
||||||
|
error?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const MenuEditForm = (props: MenuEditFormProps) => {
|
||||||
|
const { handleSave, menuTypes } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { menuFormData, setMenuFormDataHandler } = useContext(MenuFormContext)
|
||||||
|
|
||||||
|
//form hook
|
||||||
|
const methods = useForm<IMenuInfoForm>()
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
} = methods
|
||||||
|
|
||||||
|
const [blankState, setBlankState] = useState<boolean>(false)
|
||||||
|
const [menuTypeState, setMenuTypeState] = useState<string>(
|
||||||
|
menuTypes[0]?.codeId,
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (errors) {
|
||||||
|
console.log(errors)
|
||||||
|
}
|
||||||
|
}, [errors])
|
||||||
|
|
||||||
|
const [connectIdState, setConnectIdState] = useState<IConnectId>({})
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState<boolean>(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (menuFormData) {
|
||||||
|
reset(
|
||||||
|
produce(menuFormData, draft => {
|
||||||
|
if (draft) {
|
||||||
|
draft.menuKorName = draft?.menuKorName || ''
|
||||||
|
draft.menuEngName = draft?.menuEngName || ''
|
||||||
|
draft.urlPath = draft?.urlPath || ''
|
||||||
|
draft.subName = draft?.subName || ''
|
||||||
|
draft.icon = draft?.icon || ''
|
||||||
|
draft.description = draft?.description || ''
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
setMenuTypeState(menuFormData?.menuType || 'empty')
|
||||||
|
setBlankState(
|
||||||
|
menuFormData?.isBlank === null ? false : menuFormData?.isBlank,
|
||||||
|
)
|
||||||
|
setConnectIdState({
|
||||||
|
...connectIdState,
|
||||||
|
code: menuFormData?.connectId,
|
||||||
|
name: menuFormData?.connectName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [menuFormData])
|
||||||
|
|
||||||
|
const handleLinkType = (
|
||||||
|
event: React.MouseEvent<HTMLElement>,
|
||||||
|
newValue: boolean | null,
|
||||||
|
) => {
|
||||||
|
if (newValue === null) return
|
||||||
|
setBlankState(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuType = (
|
||||||
|
event: React.MouseEvent<HTMLElement>,
|
||||||
|
newValue: string | null,
|
||||||
|
) => {
|
||||||
|
if (newValue === null) return
|
||||||
|
|
||||||
|
const formOptions = {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: false,
|
||||||
|
}
|
||||||
|
setValue('connectId', null, formOptions)
|
||||||
|
setValue('urlPath', '', formOptions)
|
||||||
|
setConnectIdState({
|
||||||
|
code: null,
|
||||||
|
name: '',
|
||||||
|
error: false,
|
||||||
|
})
|
||||||
|
setMenuTypeState(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDialogOpen = () => {
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePopup = (data: any) => {
|
||||||
|
if (data) {
|
||||||
|
let codeKey = 'contentNo'
|
||||||
|
let nameKey = 'contentName'
|
||||||
|
|
||||||
|
if (menuTypeState === 'board') {
|
||||||
|
codeKey = 'boardNo'
|
||||||
|
nameKey = 'boardName'
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue('connectId', data[codeKey], {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
setConnectIdState({
|
||||||
|
code: data[codeKey],
|
||||||
|
name: data[nameKey],
|
||||||
|
error: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDialogClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveBefore = (formData: IMenuInfoForm) => {
|
||||||
|
console.log('before ', formData)
|
||||||
|
formData = produce(formData, draft => {
|
||||||
|
draft.menuType = menuTypeState
|
||||||
|
draft.menuTypeName = menuTypes.find(
|
||||||
|
item => item.codeId === menuTypeState,
|
||||||
|
).codeName
|
||||||
|
draft.isBlank = blankState
|
||||||
|
})
|
||||||
|
handleSave(formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
<form>
|
||||||
|
<Card className={classes.root}>
|
||||||
|
<CardHeader title={t('menu.info_title')} />
|
||||||
|
<Divider />
|
||||||
|
<CardContent className={classes.content}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={t('menu.no')}
|
||||||
|
name="menuId"
|
||||||
|
variant="outlined"
|
||||||
|
value={menuFormData ? menuFormData.menuId : ''}
|
||||||
|
disabled
|
||||||
|
margin="dense"
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="menuKorName"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={t('menu.name')}
|
||||||
|
required
|
||||||
|
variant="outlined"
|
||||||
|
margin="dense"
|
||||||
|
inputRef={field.ref}
|
||||||
|
value={field.value}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
defaultValue={''}
|
||||||
|
rules={{ required: true, maxLength: 100 }}
|
||||||
|
/>
|
||||||
|
{errors.menuKorName && (
|
||||||
|
<ValidationAlert
|
||||||
|
fieldError={errors.menuKorName}
|
||||||
|
target={[100]}
|
||||||
|
label={t('menu.name')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="menuEngName"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={t('menu.eng_name')}
|
||||||
|
required
|
||||||
|
variant="outlined"
|
||||||
|
margin="dense"
|
||||||
|
inputRef={field.ref}
|
||||||
|
value={field.value}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
defaultValue={''}
|
||||||
|
rules={{ required: true, maxLength: 200 }}
|
||||||
|
/>
|
||||||
|
{errors.menuEngName && (
|
||||||
|
<ValidationAlert
|
||||||
|
fieldError={errors.menuEngName}
|
||||||
|
target={[100]}
|
||||||
|
label={t('menu.eng_name')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormHelperText>{t('menu.type')}</FormHelperText>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
aria-label="menu type button group"
|
||||||
|
className={classes.buttons}
|
||||||
|
value={menuTypeState}
|
||||||
|
onChange={handleMenuType}
|
||||||
|
exclusive
|
||||||
|
>
|
||||||
|
{menuTypes?.map(item => (
|
||||||
|
<ToggleButton
|
||||||
|
key={`menuType-${item.codeId}`}
|
||||||
|
value={item.codeId}
|
||||||
|
>
|
||||||
|
{item.codeName}
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
|
{(menuTypeState === 'inside' || menuTypeState === 'outside') && (
|
||||||
|
<>
|
||||||
|
<Controller
|
||||||
|
name="urlPath"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={t('menu.url_path')}
|
||||||
|
required
|
||||||
|
variant="outlined"
|
||||||
|
margin="dense"
|
||||||
|
inputRef={field.ref}
|
||||||
|
value={field.value}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
defaultValue={''}
|
||||||
|
rules={{ required: true, maxLength: 200 }}
|
||||||
|
/>
|
||||||
|
{menuTypeState === 'outside' && (
|
||||||
|
<FormHelperText>{t('menu.outside_link_help')}</FormHelperText>
|
||||||
|
)}
|
||||||
|
{errors.urlPath && (
|
||||||
|
<ValidationAlert
|
||||||
|
fieldError={errors.urlPath}
|
||||||
|
target={[200]}
|
||||||
|
label={t('menu.url_path')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(menuTypeState === 'contents' || menuTypeState === 'board') && (
|
||||||
|
<>
|
||||||
|
<Paper component="div" className={classes.search}>
|
||||||
|
<TextField
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
margin="dense"
|
||||||
|
label={`${
|
||||||
|
menuTypes.find(item => item.codeId === menuTypeState)
|
||||||
|
.codeName
|
||||||
|
} ${t('common.select')}`}
|
||||||
|
error={!!connectIdState.error}
|
||||||
|
value={connectIdState.name || ''}
|
||||||
|
required
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="connectId"
|
||||||
|
type="text"
|
||||||
|
hidden
|
||||||
|
value={connectIdState.code || ''}
|
||||||
|
{...register('connectId', { required: true })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider
|
||||||
|
className={classes.verticalDivider}
|
||||||
|
orientation="vertical"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className={classes.searchButton}
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleDialogOpen}
|
||||||
|
>{`${
|
||||||
|
menuTypes.find(item => item.codeId === menuTypeState)
|
||||||
|
.codeName
|
||||||
|
} ${t('label.button.find')}`}</Button>
|
||||||
|
|
||||||
|
<DialogPopup
|
||||||
|
id="find-dialog"
|
||||||
|
children={
|
||||||
|
menuTypeState === 'contents' ? (
|
||||||
|
<Content handlePopup={handlePopup} />
|
||||||
|
) : (
|
||||||
|
<Board handlePopup={handlePopup} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
handleClose={handleDialogClose}
|
||||||
|
open={dialogOpen}
|
||||||
|
title={`${
|
||||||
|
menuTypes.find(item => item.codeId === menuTypeState)
|
||||||
|
.codeName
|
||||||
|
} ${t('label.button.find')}`}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
{errors.connectId && (
|
||||||
|
<ValidationAlert
|
||||||
|
fieldError={errors.connectId}
|
||||||
|
label={`${
|
||||||
|
menuTypes.find(item => item.codeId === menuTypeState)
|
||||||
|
.codeName
|
||||||
|
} ${t('common.select')}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{menuTypeState !== 'empty' && (
|
||||||
|
<>
|
||||||
|
<FormHelperText>{t('menu.connect_type')}</FormHelperText>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
aria-label="link type button group"
|
||||||
|
className={classes.buttons}
|
||||||
|
value={blankState}
|
||||||
|
onChange={handleLinkType}
|
||||||
|
>
|
||||||
|
<ToggleButton value={false} aria-label={t('menu.self')}>
|
||||||
|
{t('menu.self')}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value={true} aria-label={t('menu.blank')}>
|
||||||
|
{t('menu.blank')}
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="subName"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={t('menu.sub_name')}
|
||||||
|
variant="outlined"
|
||||||
|
margin="dense"
|
||||||
|
inputRef={field.ref}
|
||||||
|
value={field.value}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
defaultValue={''}
|
||||||
|
rules={{ maxLength: 200 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errors.subName && (
|
||||||
|
<ValidationAlert fieldError={errors.subName} target={[200]} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="icon"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={t('menu.icon')}
|
||||||
|
variant="outlined"
|
||||||
|
margin="dense"
|
||||||
|
inputRef={field.ref}
|
||||||
|
value={field.value}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
defaultValue={''}
|
||||||
|
rules={{ maxLength: 100 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errors.icon && (
|
||||||
|
<ValidationAlert fieldError={errors.icon} target={[100]} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="description"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={t('menu.description')}
|
||||||
|
variant="outlined"
|
||||||
|
margin="dense"
|
||||||
|
multiline={true}
|
||||||
|
maxRows={3}
|
||||||
|
minRows={2}
|
||||||
|
inputRef={field.ref}
|
||||||
|
value={field.value}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
defaultValue={''}
|
||||||
|
rules={{ maxLength: 500 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errors.description && (
|
||||||
|
<ValidationAlert fieldError={errors.description} target={[500]} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormGroup row>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Controller
|
||||||
|
name="isUse"
|
||||||
|
render={({ field: { onChange, ref, value } }) => (
|
||||||
|
<Switch
|
||||||
|
inputProps={{ 'aria-label': 'secondary checkbox' }}
|
||||||
|
onChange={onChange}
|
||||||
|
inputRef={ref}
|
||||||
|
checked={value}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
defaultValue={
|
||||||
|
typeof menuFormData?.isUse !== 'undefined'
|
||||||
|
? menuFormData?.isUse
|
||||||
|
: true
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={t('common.use_at')}
|
||||||
|
labelPlacement="start"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Controller
|
||||||
|
name="isShow"
|
||||||
|
render={({ field: { onChange, ref, value } }) => (
|
||||||
|
<Switch
|
||||||
|
inputProps={{ 'aria-label': 'secondary checkbox' }}
|
||||||
|
onChange={onChange}
|
||||||
|
inputRef={ref}
|
||||||
|
checked={value}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
defaultValue={
|
||||||
|
typeof menuFormData?.isShow !== 'undefined'
|
||||||
|
? menuFormData?.isShow
|
||||||
|
: true
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={t('menu.show_at')}
|
||||||
|
labelPlacement="start"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</form>
|
||||||
|
<DetailButtons handleSave={handleSubmit(handleSaveBefore)} />
|
||||||
|
</FormProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MenuEditForm }
|
||||||
93
frontend/admin/src/components/EditForm/ValidationAlert.tsx
Normal file
93
frontend/admin/src/components/EditForm/ValidationAlert.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { FieldError } from 'react-hook-form'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import Alert, { AlertProps } from '@material-ui/lab/Alert'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
|
||||||
|
import { format } from '@utils'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
padding: `0px ${theme.spacing(1)}px`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ValidaltionAlertProps extends AlertProps {
|
||||||
|
message?: string
|
||||||
|
fieldError?: FieldError
|
||||||
|
target?: any[]
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const validMessages = {
|
||||||
|
required: {
|
||||||
|
code: 'valid.required', // 값은 필수입니다.
|
||||||
|
isFormat: false,
|
||||||
|
},
|
||||||
|
min: {
|
||||||
|
code: 'valid.between.format', // {0} ~ {1} 사이의 값을 입력해주세요.
|
||||||
|
isFormat: true,
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
code: 'valid.between.format', // {0} ~ {1} 사이의 값을 입력해주세요.
|
||||||
|
isFormat: true,
|
||||||
|
},
|
||||||
|
maxLength: {
|
||||||
|
code: 'valid.maxlength.format', // {0}자 이하로 입력해주세요.
|
||||||
|
isFormat: true,
|
||||||
|
},
|
||||||
|
minLength: {
|
||||||
|
code: 'valid.minlength.format', // {0}자 이상으로 입력해주세요.
|
||||||
|
isFormat: true,
|
||||||
|
},
|
||||||
|
valueAsNumber: {
|
||||||
|
code: 'valid.valueAsNumber', // 숫자만 입력가능합니다.
|
||||||
|
isFormat: false,
|
||||||
|
},
|
||||||
|
valueAsDate: {
|
||||||
|
code: 'valid.valueAsDate', // 날짜 형식으로 입력해주세요.
|
||||||
|
isFormat: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const ValidationAlert = (props: ValidaltionAlertProps) => {
|
||||||
|
const { message, fieldError, target, label, ...rest } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [validMessage, setValidMessage] = useState<string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (message) {
|
||||||
|
setValidMessage(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldError.message) {
|
||||||
|
setValidMessage(fieldError.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = validMessages[fieldError.type]
|
||||||
|
if (valid.isFormat) {
|
||||||
|
setValidMessage(format(t(valid.code), target))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setValidMessage(`${label} ${t(valid.code)}`)
|
||||||
|
}, [message, fieldError])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
className={classes.root}
|
||||||
|
severity="error"
|
||||||
|
variant="outlined"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{validMessage}
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ValidationAlert
|
||||||
2
frontend/admin/src/components/EditForm/index.tsx
Normal file
2
frontend/admin/src/components/EditForm/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './MenuEditForm'
|
||||||
|
export * from './ValidationAlert'
|
||||||
71
frontend/admin/src/components/Editor/index.tsx
Normal file
71
frontend/admin/src/components/Editor/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import Loader from '@components/Loader'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
paddingTop: theme.spacing(1),
|
||||||
|
'& .ck-editor__editable_inline': {
|
||||||
|
minHeight: '200px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface IEditor {
|
||||||
|
contents: string
|
||||||
|
setContents: (data: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Editor = (props: IEditor) => {
|
||||||
|
const { contents, setContents } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const editorRef = useRef<any>()
|
||||||
|
const [editorLoaded, setEditorLoaded] = useState<boolean>(false)
|
||||||
|
const { CKEditor, ClassicEditor } = editorRef.current || {}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
editorRef.current = {
|
||||||
|
CKEditor: require('@ckeditor/ckeditor5-react').CKEditor,
|
||||||
|
ClassicEditor: require('@ckeditor/ckeditor5-build-classic'),
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditorLoaded(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{editorLoaded ? (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<CKEditor
|
||||||
|
editor={ClassicEditor}
|
||||||
|
data={contents}
|
||||||
|
config={{
|
||||||
|
ckfinder: {
|
||||||
|
uploadUrl: `/api/editor`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onReady={(editor: any) => {
|
||||||
|
console.info('editor is ready to use', editor)
|
||||||
|
}}
|
||||||
|
onChange={(event: any, editor: any) => {
|
||||||
|
const chanagedata = editor.getData()
|
||||||
|
setContents(chanagedata)
|
||||||
|
}}
|
||||||
|
onBlur={(event: any, editor: any) => {
|
||||||
|
console.info('Blur.', editor)
|
||||||
|
}}
|
||||||
|
onFocus={(event: any, editor: any) => {
|
||||||
|
console.info('Focus.', editor)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Loader />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Editor
|
||||||
103
frontend/admin/src/components/Layout/Bread.tsx
Normal file
103
frontend/admin/src/components/Layout/Bread.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
import Breadcrumbs from '@material-ui/core/Breadcrumbs'
|
||||||
|
import Link from '@material-ui/core/Link'
|
||||||
|
import { Theme, makeStyles } from '@material-ui/core/styles'
|
||||||
|
import { currentMenuStateAtom, flatMenusSelect } from '@stores'
|
||||||
|
import { useRecoilValue } from 'recoil'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) => ({
|
||||||
|
root: {
|
||||||
|
// marginBottom: theme.spacing(1),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const Bread: React.FC = () => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const router = useRouter()
|
||||||
|
const flatMenus = useRecoilValue(flatMenusSelect)
|
||||||
|
const current = useRecoilValue(currentMenuStateAtom)
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
|
||||||
|
const hierarchy = useCallback(() => {
|
||||||
|
if (!current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current?.level === 1) {
|
||||||
|
return (
|
||||||
|
<Typography color="textPrimary">
|
||||||
|
{i18n.language === 'ko' ? current?.korName : current?.engName}
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let trees = []
|
||||||
|
const arr = flatMenus.slice(
|
||||||
|
0,
|
||||||
|
flatMenus.findIndex(item => item.id === current.id) + 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
trees.push(current)
|
||||||
|
arr.reverse().some(item => {
|
||||||
|
if (item.level < current.level) {
|
||||||
|
trees.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.level === 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let nodes = trees.reverse().map(item =>
|
||||||
|
item.id === current.id ? (
|
||||||
|
<Typography key={current.id} color="textPrimary">
|
||||||
|
{i18n.language === 'ko' ? current?.korName : current?.engName}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
key={`brean-link-${item.id}`}
|
||||||
|
color="inherit"
|
||||||
|
href="/getting-started/installation/"
|
||||||
|
onClick={(event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||||
|
handleClick(event, item.urlPath)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n.language === 'ko' ? item.korName : item.engName}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
}, [current])
|
||||||
|
|
||||||
|
const handleClick = (
|
||||||
|
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (url) {
|
||||||
|
router.push(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<Breadcrumbs separator="›" aria-label="breadcrumb">
|
||||||
|
<Link
|
||||||
|
color="inherit"
|
||||||
|
href="/"
|
||||||
|
onClick={(event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||||
|
handleClick(event, '/')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
{hierarchy()}
|
||||||
|
</Breadcrumbs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Bread
|
||||||
25
frontend/admin/src/components/Layout/Footer.tsx
Normal file
25
frontend/admin/src/components/Layout/Footer.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
import Container from '@material-ui/core/Container'
|
||||||
|
import { makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import Copyright from '@components/Copyright'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) => ({
|
||||||
|
footer: {
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
marginTop: 'auto',
|
||||||
|
backgroundColor: theme.palette.background.default,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
const Footer: React.FC = () => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container component="footer" className={classes.footer}>
|
||||||
|
<Typography variant="body1">Footer</Typography>
|
||||||
|
<Copyright />
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Footer
|
||||||
103
frontend/admin/src/components/Layout/Header.tsx
Normal file
103
frontend/admin/src/components/Layout/Header.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import AppBar from '@material-ui/core/AppBar'
|
||||||
|
import Toolbar from '@material-ui/core/Toolbar'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import MenuIcon from '@material-ui/icons/Menu'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
import { Theme, makeStyles, createStyles } from '@material-ui/core/styles'
|
||||||
|
|
||||||
|
import { DEFAULT_APP_NAME, DRAWER_WIDTH } from '@constants'
|
||||||
|
import Profile from './Profile'
|
||||||
|
import useUser from '@hooks/useUser'
|
||||||
|
import { useRecoilValue } from 'recoil'
|
||||||
|
import { currentMenuStateAtom } from '@stores'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
menuButton: {
|
||||||
|
marginRight: theme.spacing(2),
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
appBar: {
|
||||||
|
zIndex: theme.zIndex.drawer + 1,
|
||||||
|
transition: theme.transitions.create(['width', 'margin'], {
|
||||||
|
easing: theme.transitions.easing.sharp,
|
||||||
|
duration: theme.transitions.duration.leavingScreen,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
appBarShift: {
|
||||||
|
marginLeft: DRAWER_WIDTH,
|
||||||
|
width: `calc(100% - ${DRAWER_WIDTH}px)`,
|
||||||
|
transition: theme.transitions.create(['width', 'margin'], {
|
||||||
|
easing: theme.transitions.easing.sharp,
|
||||||
|
duration: theme.transitions.duration.enteringScreen,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
hide: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
toolbar: {
|
||||||
|
paddingRight: 24, // keep right padding when drawer closed
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface IHeaderProps {
|
||||||
|
open: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header: React.FC<IHeaderProps> = ({ open, onClick }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const { user } = useUser()
|
||||||
|
const currentMenu = useRecoilValue(currentMenuStateAtom)
|
||||||
|
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
|
||||||
|
const getTitle = useCallback(() => {
|
||||||
|
if (currentMenu) {
|
||||||
|
return i18n.language === 'ko'
|
||||||
|
? currentMenu?.korName
|
||||||
|
: currentMenu?.engName
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_APP_NAME
|
||||||
|
}, [i18n, currentMenu])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBar
|
||||||
|
position="fixed"
|
||||||
|
className={clsx(classes.appBar, {
|
||||||
|
[classes.appBarShift]: open,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Toolbar className={classes.toolbar}>
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
aria-label="open drawer"
|
||||||
|
onClick={onClick}
|
||||||
|
edge="start"
|
||||||
|
className={clsx(classes.menuButton, {
|
||||||
|
[classes.hide]: open,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="h4" noWrap className={classes.title}>
|
||||||
|
{getTitle()}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{user && <Profile id={user.userId} email={user.email} />}
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header
|
||||||
71
frontend/admin/src/components/Layout/Profile.tsx
Normal file
71
frontend/admin/src/components/Layout/Profile.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Link, Typography } from '@material-ui/core'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import Menu from '@material-ui/core/Menu'
|
||||||
|
import MenuItem from '@material-ui/core/MenuItem'
|
||||||
|
import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export interface IProfileProps {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Profile: React.FC<IProfileProps> = ({ id, email }) => {
|
||||||
|
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null)
|
||||||
|
const open = Boolean(anchorEl)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProfileClick = () => {
|
||||||
|
handleClose()
|
||||||
|
router.push(`/user/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<IconButton
|
||||||
|
aria-label="account of current user"
|
||||||
|
aria-controls="menu-appbar"
|
||||||
|
aria-haspopup="true"
|
||||||
|
onClick={handleMenu}
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle1">{email}</Typography>
|
||||||
|
<KeyboardArrowDownIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Menu
|
||||||
|
id="menu-appbar"
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
keepMounted
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={handleProfileClick}>Profile</MenuItem>
|
||||||
|
<MenuItem>
|
||||||
|
<Link href="/auth/logout">{t('common.logout')}</Link>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Profile
|
||||||
144
frontend/admin/src/components/Layout/SideBar.tsx
Normal file
144
frontend/admin/src/components/Layout/SideBar.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import Drawer from '@material-ui/core/Drawer'
|
||||||
|
import Divider from '@material-ui/core/Divider'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'
|
||||||
|
import Link from '@material-ui/core/Link'
|
||||||
|
|
||||||
|
import { DRAWER_WIDTH } from '@constants'
|
||||||
|
import { Menu } from '@components/Menu'
|
||||||
|
import { Typography } from '@material-ui/core'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
display: 'flex',
|
||||||
|
},
|
||||||
|
hide: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
drawer: {
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
flexShrink: 0,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
},
|
||||||
|
drawerOpen: {
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
transition: theme.transitions.create('width', {
|
||||||
|
easing: theme.transitions.easing.sharp,
|
||||||
|
duration: theme.transitions.duration.enteringScreen,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
drawerClose: {
|
||||||
|
transition: theme.transitions.create('width', {
|
||||||
|
easing: theme.transitions.easing.sharp,
|
||||||
|
duration: theme.transitions.duration.leavingScreen,
|
||||||
|
}),
|
||||||
|
overflowX: 'hidden',
|
||||||
|
width: theme.spacing(7) + 1,
|
||||||
|
[theme.breakpoints.up('sm')]: {
|
||||||
|
width: theme.spacing(9) + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
drawerHeader: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: theme.palette.primary.main,
|
||||||
|
padding: theme.spacing(0, 1),
|
||||||
|
// necessary for content to be below app bar
|
||||||
|
...theme.mixins.toolbar,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
// position: 'relative',
|
||||||
|
padding: theme.spacing(0, 1),
|
||||||
|
zIndex: 4,
|
||||||
|
'&:after': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '0',
|
||||||
|
|
||||||
|
height: '1px',
|
||||||
|
right: '15px',
|
||||||
|
width: 'calc(100% - 30px)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logoLink: {
|
||||||
|
marginLeft: '5px',
|
||||||
|
padding: '5px 0',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
textAlign: 'left',
|
||||||
|
lineHeight: '30px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: theme.palette.common.white,
|
||||||
|
},
|
||||||
|
img: {
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
border: '0',
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ISideBarProps {
|
||||||
|
open: boolean
|
||||||
|
onClick: () => void
|
||||||
|
logo?: string
|
||||||
|
logoText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SideBar = (props: ISideBarProps) => {
|
||||||
|
const { open, onClick, logo, logoText } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const onLogoClick = (e: React.SyntheticEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
className={clsx(classes.drawer, {
|
||||||
|
[classes.drawerOpen]: open,
|
||||||
|
[classes.drawerClose]: !open,
|
||||||
|
})}
|
||||||
|
classes={{
|
||||||
|
paper: clsx({
|
||||||
|
[classes.drawerOpen]: open,
|
||||||
|
[classes.drawerClose]: !open,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={classes.drawerHeader}>
|
||||||
|
{logo && (
|
||||||
|
<div className={classes.logo}>
|
||||||
|
<Link href="/" onClick={onLogoClick} className={classes.logoLink}>
|
||||||
|
<img alt="Logo" src={logo} className={classes.img} />
|
||||||
|
<Typography className={classes.logoLink} variant="h4">
|
||||||
|
{logoText}
|
||||||
|
</Typography>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<IconButton onClick={onClick}>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Menu open={open} />
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SideBar
|
||||||
81
frontend/admin/src/components/Layout/index.tsx
Normal file
81
frontend/admin/src/components/Layout/index.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Container, Grid } from '@material-ui/core'
|
||||||
|
import { makeStyles, createStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import SideBar from './SideBar'
|
||||||
|
import Header from './Header'
|
||||||
|
import Footer from './Footer'
|
||||||
|
import { PageProps } from '@pages/_app'
|
||||||
|
import Bread from './Bread'
|
||||||
|
import { ADMIN_LOGO_PATH, ADMIN_LOGO_TEXT } from '@constants'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
display: 'flex',
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: '100vh',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
paddingTop: theme.spacing(2),
|
||||||
|
paddingBottom: theme.spacing(2),
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
},
|
||||||
|
toolbar: {
|
||||||
|
paddingRight: 24, // keep right padding when drawer closed
|
||||||
|
},
|
||||||
|
appBarSpacer: theme.mixins.toolbar,
|
||||||
|
authContent: {
|
||||||
|
padding: '2.5rem',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ILayoutProps extends PageProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Layout: React.FC<ILayoutProps> = props => {
|
||||||
|
const { children, className } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
|
||||||
|
const handleDrawerOpen = () => {
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrawerClose = () => {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${classes.root} ${className}`}>
|
||||||
|
{/* <CssBaseline /> */}
|
||||||
|
<Header open={open} onClick={handleDrawerOpen} />
|
||||||
|
|
||||||
|
<SideBar
|
||||||
|
open={open}
|
||||||
|
onClick={handleDrawerClose}
|
||||||
|
logoText={ADMIN_LOGO_TEXT}
|
||||||
|
logo={ADMIN_LOGO_PATH}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className={classes.content}>
|
||||||
|
<div className={classes.appBarSpacer} />
|
||||||
|
<Container maxWidth="lg" className={classes.container}>
|
||||||
|
<Bread />
|
||||||
|
{children}
|
||||||
|
<Grid container spacing={3}></Grid>
|
||||||
|
</Container>
|
||||||
|
<Footer />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Layout }
|
||||||
25
frontend/admin/src/components/Loader/index.tsx
Normal file
25
frontend/admin/src/components/Loader/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { CircularProgress, Container } from '@material-ui/core'
|
||||||
|
import { Theme, makeStyles } from '@material-ui/core/styles'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) => ({
|
||||||
|
container: {
|
||||||
|
display: 'flex',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingTop: theme.spacing(10),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const Loader: React.FC = () => {
|
||||||
|
const classes = useStyles()
|
||||||
|
return (
|
||||||
|
<Container className={classes.container}>
|
||||||
|
<CircularProgress size={40} />
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Loader
|
||||||
10
frontend/admin/src/components/LoginLayout/index.tsx
Normal file
10
frontend/admin/src/components/LoginLayout/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import Box from '@material-ui/core/Box'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
type Props = {}
|
||||||
|
|
||||||
|
const LoginLayout: React.FC<Props> = ({ children }) => {
|
||||||
|
return <Box>{children}</Box>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginLayout
|
||||||
119
frontend/admin/src/components/Menu/MenuItem.tsx
Normal file
119
frontend/admin/src/components/Menu/MenuItem.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import Collapse from '@material-ui/core/Collapse'
|
||||||
|
import Icon from '@material-ui/core/Icon'
|
||||||
|
import List from '@material-ui/core/List'
|
||||||
|
import ListItem from '@material-ui/core/ListItem'
|
||||||
|
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||||
|
import ListItemText from '@material-ui/core/ListItemText'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import ExpandLess from '@material-ui/icons/ExpandLess'
|
||||||
|
import ExpandMore from '@material-ui/icons/ExpandMore'
|
||||||
|
import { currentMenuStateAtom, ISideMenu } from '@stores'
|
||||||
|
import theme from '@styles/theme'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import React, { useCallback, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useRecoilValue } from 'recoil'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
'& .MuiListItemIcon-root': {
|
||||||
|
minWidth: '36px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
menuItem: {
|
||||||
|
borderBottom: '1px solid #edf1f7',
|
||||||
|
display: 'flex',
|
||||||
|
overflow: 'hidden',
|
||||||
|
width: 'auto',
|
||||||
|
transition: 'all 300ms linear',
|
||||||
|
position: 'relative',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface IMenuItemProps extends ISideMenu {
|
||||||
|
drawerOpen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @TODO
|
||||||
|
* 3단계 이상 그려지는 메뉴 처리
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MenuItem: React.FC<IMenuItemProps> = props => {
|
||||||
|
const { expanded, drawerOpen } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const router = useRouter()
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
|
||||||
|
const current = useRecoilValue(currentMenuStateAtom)
|
||||||
|
|
||||||
|
const [open, setOpen] = useState<boolean>(expanded || false)
|
||||||
|
|
||||||
|
const onClick = (item: ISideMenu) => {
|
||||||
|
if (item.children.filter(i => i.isShow).length > 0) {
|
||||||
|
setOpen(!open)
|
||||||
|
} else {
|
||||||
|
router.push(item.urlPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawItem = useCallback(
|
||||||
|
(item: ISideMenu) => {
|
||||||
|
const active =
|
||||||
|
current?.id === item.id
|
||||||
|
? true
|
||||||
|
: item.children?.findIndex(ele => ele.id === current?.id) > -1
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`list-item-div-${item.id}`} className={classes.root}>
|
||||||
|
<ListItem
|
||||||
|
button
|
||||||
|
key={`list-item-${item.id}`}
|
||||||
|
onClick={() => onClick(item)}
|
||||||
|
className={`${classes.menuItem} ${active ? classes.active : null}`}
|
||||||
|
style={{
|
||||||
|
paddingLeft: theme.spacing(
|
||||||
|
item.level * (item.level === 1 ? 3 : 2),
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon className={active ? classes.active : null}>
|
||||||
|
<Icon> {item.icon || 'folder'}</Icon>
|
||||||
|
</ListItemIcon>
|
||||||
|
|
||||||
|
{drawerOpen && (
|
||||||
|
<ListItemText
|
||||||
|
key={`item-text-${item.id}`}
|
||||||
|
primary={i18n.language === 'ko' ? item.korName : item.engName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{drawerOpen &&
|
||||||
|
item.children.filter(i => i.isShow).length > 0 &&
|
||||||
|
(open ? <ExpandLess /> : <ExpandMore />)}
|
||||||
|
</ListItem>
|
||||||
|
{item.children.filter(i => i.isShow).length > 0 ? (
|
||||||
|
<Collapse in={open && drawerOpen} timeout="auto" unmountOnExit>
|
||||||
|
<List component="div" disablePadding>
|
||||||
|
{item.children.filter(i => i.isShow).map(i => drawItem(i))}
|
||||||
|
</List>
|
||||||
|
</Collapse>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[props, open],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <>{drawItem(props)}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MenuItem
|
||||||
36
frontend/admin/src/components/Menu/index.tsx
Normal file
36
frontend/admin/src/components/Menu/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { List } from '@material-ui/core'
|
||||||
|
import { makeStyles, createStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import MenuItem from './MenuItem'
|
||||||
|
import { useRecoilValue } from 'recoil'
|
||||||
|
import { menuStateAtom } from '@stores'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
listStyle: 'none',
|
||||||
|
position: 'unset',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface IMenuProps {
|
||||||
|
open: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Menu = ({ open }: IMenuProps) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const menus = useRecoilValue(menuStateAtom)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List component="nav" className={classes.root}>
|
||||||
|
{menus
|
||||||
|
.filter(item => item.isShow)
|
||||||
|
.map(item => (
|
||||||
|
<MenuItem key={`menu-item-${item.id}`} {...item} drawerOpen={open} />
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Menu }
|
||||||
65
frontend/admin/src/components/RadioGroupField/index.tsx
Normal file
65
frontend/admin/src/components/RadioGroupField/index.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import FormControl from '@material-ui/core/FormControl'
|
||||||
|
import FormControlLabel, {
|
||||||
|
FormControlLabelProps,
|
||||||
|
} from '@material-ui/core/FormControlLabel'
|
||||||
|
import FormLabel from '@material-ui/core/FormLabel'
|
||||||
|
import Radio from '@material-ui/core/Radio'
|
||||||
|
import RadioGroup from '@material-ui/core/RadioGroup'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.23)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: theme.spacing(0, 1),
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface IRadioData extends Omit<FormControlLabelProps, 'control'> {}
|
||||||
|
|
||||||
|
export interface RadioGroupFieldProps {
|
||||||
|
data: IRadioData[]
|
||||||
|
label: string
|
||||||
|
required?: boolean
|
||||||
|
error?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const RadioGroupField = ({
|
||||||
|
data,
|
||||||
|
label,
|
||||||
|
required = false,
|
||||||
|
error = false,
|
||||||
|
}: RadioGroupFieldProps) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
return (
|
||||||
|
<FormControl error={error} component="fieldset" className={classes.root}>
|
||||||
|
<FormLabel
|
||||||
|
className={classes.label}
|
||||||
|
error={error}
|
||||||
|
required={required}
|
||||||
|
component="legend"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</FormLabel>
|
||||||
|
<RadioGroup row aria-label="position" name="position" defaultValue="top">
|
||||||
|
{data &&
|
||||||
|
data.map((item, index) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={`radio-group-${label}-${index}`}
|
||||||
|
control={<Radio color="primary" />}
|
||||||
|
{...item}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RadioGroupField
|
||||||
197
frontend/admin/src/components/Reserve/ReserveClientInfo.tsx
Normal file
197
frontend/admin/src/components/Reserve/ReserveClientInfo.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { ControlledTextField } from '@components/ControlledField'
|
||||||
|
import DialogPopup from '@components/DialogPopup'
|
||||||
|
import DisableTextField from '@components/DisableTextField'
|
||||||
|
import Button from '@material-ui/core/Button'
|
||||||
|
import Card from '@material-ui/core/Card'
|
||||||
|
import CardActions from '@material-ui/core/CardActions'
|
||||||
|
import CardContent from '@material-ui/core/CardContent'
|
||||||
|
import CardHeader from '@material-ui/core/CardHeader'
|
||||||
|
import Collapse from '@material-ui/core/Collapse'
|
||||||
|
import Divider from '@material-ui/core/Divider'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
import ExpandLessIcon from '@material-ui/icons/ExpandLess'
|
||||||
|
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
|
||||||
|
import User from '@pages/user'
|
||||||
|
import { IReserve, IUser, ReserveFormProps } from '@service'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
'& .MuiInputLabel-outlined': {
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
marginLeft: theme.spacing(4),
|
||||||
|
padding: theme.spacing(0, 1),
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '2rem',
|
||||||
|
},
|
||||||
|
pos: {
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
marginBottom: '3rem',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ReserveClientInfoProps extends ReserveFormProps {
|
||||||
|
data: IReserve
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IUserInfo {
|
||||||
|
email: string
|
||||||
|
userId: string
|
||||||
|
userName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReserveClientInfo = (props: ReserveClientInfoProps) => {
|
||||||
|
const { control, formState, data, setValue } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState<boolean>(true)
|
||||||
|
const [dialogOpen, setDialogOpen] = useState<boolean>(false)
|
||||||
|
const [user, setUser] = useState<IUserInfo | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
setUser({
|
||||||
|
email: data.userEmail,
|
||||||
|
userId: data.userId,
|
||||||
|
userName: data.userName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const handleExpandClick = () => {
|
||||||
|
setExpanded(!expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePopup = (userData: IUser) => {
|
||||||
|
if (userData) {
|
||||||
|
setUser(userData)
|
||||||
|
setValue('userEmail', userData.email, {
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
setValue('userId', userData.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDialogClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDialogOpen = () => {
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={classes.root}>
|
||||||
|
<CardActions className={classes.header}>
|
||||||
|
<CardHeader title={`${t('reserve.user')} ${t('common.information')}`} />
|
||||||
|
<IconButton onClick={handleExpandClick}>
|
||||||
|
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||||
|
</IconButton>
|
||||||
|
</CardActions>
|
||||||
|
<Divider />
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
{user ? (
|
||||||
|
<CardContent className={classes.container}>
|
||||||
|
<DisableTextField
|
||||||
|
label={t('label.title.name')}
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{user.userName}
|
||||||
|
<Button
|
||||||
|
className={classes.button}
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={handleDialogOpen}
|
||||||
|
>
|
||||||
|
{`${t('reserve.user')} ${t('common.search')}`}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
labelProps={{
|
||||||
|
xs: 4,
|
||||||
|
sm: 2,
|
||||||
|
}}
|
||||||
|
valueProps={{
|
||||||
|
xs: 8,
|
||||||
|
sm: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="userContactNo"
|
||||||
|
label={t('reserve.phone')}
|
||||||
|
defaultValue={''}
|
||||||
|
textFieldProps={{
|
||||||
|
required: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="userEmail"
|
||||||
|
label={t('user.email')}
|
||||||
|
defaultValue={user?.email || ''}
|
||||||
|
textFieldProps={{
|
||||||
|
required: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
) : (
|
||||||
|
<CardContent className={classes.content}>
|
||||||
|
<Typography
|
||||||
|
className={classes.pos}
|
||||||
|
variant="h5"
|
||||||
|
color="textSecondary"
|
||||||
|
>
|
||||||
|
{t('reserve.msg.find_user')}
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={handleDialogOpen}
|
||||||
|
>
|
||||||
|
{`${t('reserve.user')} ${t('common.search')}`}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
<DialogPopup
|
||||||
|
id="find-dialog"
|
||||||
|
handleClose={handleDialogClose}
|
||||||
|
open={dialogOpen}
|
||||||
|
title={`${t('common.user')} ${t('label.button.find')}`}
|
||||||
|
>
|
||||||
|
<User handlePopup={handlePopup} />
|
||||||
|
</DialogPopup>
|
||||||
|
</Collapse>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ReserveClientInfo }
|
||||||
60
frontend/admin/src/components/Reserve/ReserveEduInfo.tsx
Normal file
60
frontend/admin/src/components/Reserve/ReserveEduInfo.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { ControlledTextField } from '@components/ControlledField'
|
||||||
|
import { IReserve, ReserveFormProps } from '@service'
|
||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import { UseFormSetError, useWatch } from 'react-hook-form'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface ReserveEduInfoProps extends ReserveFormProps {
|
||||||
|
totalQty: number
|
||||||
|
setError: UseFormSetError<IReserve>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReserveEduInfo = (props: ReserveEduInfoProps) => {
|
||||||
|
const { control, formState, totalQty, setError } = props
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const watchReserveQty = useWatch({
|
||||||
|
control,
|
||||||
|
name: 'reserveQty',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchReserveQty) {
|
||||||
|
if (watchReserveQty > totalQty) {
|
||||||
|
setError(
|
||||||
|
'reserveQty',
|
||||||
|
{ message: t('valid.reserve.number_of_people') },
|
||||||
|
{ shouldFocus: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [watchReserveQty])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="reserveQty"
|
||||||
|
label={`${t('reserve.request')} ${t('reserve.number_of_people')}`}
|
||||||
|
defaultValue={''}
|
||||||
|
textFieldProps={{
|
||||||
|
required: true,
|
||||||
|
type: 'number',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="reservePurposeContent"
|
||||||
|
label={`${t('reserve')} ${t('reserve.purpose')}`}
|
||||||
|
defaultValue={''}
|
||||||
|
textFieldProps={{
|
||||||
|
required: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ReserveEduInfo }
|
||||||
231
frontend/admin/src/components/Reserve/ReserveEquipInfo.tsx
Normal file
231
frontend/admin/src/components/Reserve/ReserveEquipInfo.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import {
|
||||||
|
ControlledDateRangePicker,
|
||||||
|
ControlledTextField,
|
||||||
|
} from '@components/ControlledField'
|
||||||
|
import { convertStringToDate, convertStringToDateFormat } from '@libs/date'
|
||||||
|
import FormHelperText from '@material-ui/core/FormHelperText'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import {
|
||||||
|
IReserve,
|
||||||
|
IReserveItemRelation,
|
||||||
|
ReserveFormProps,
|
||||||
|
reserveService,
|
||||||
|
} from '@service'
|
||||||
|
import { errorStateSelector } from '@stores'
|
||||||
|
import { format } from '@utils'
|
||||||
|
import isAfter from 'date-fns/isAfter'
|
||||||
|
import isBefore from 'date-fns/isBefore'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { UseFormClearErrors, UseFormSetError, useWatch } from 'react-hook-form'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSetRecoilState } from 'recoil'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
help: {
|
||||||
|
marginLeft: theme.spacing(2),
|
||||||
|
width: '20em',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ReserveEquipInfoProps extends ReserveFormProps {
|
||||||
|
item: IReserveItemRelation
|
||||||
|
setError: UseFormSetError<IReserve>
|
||||||
|
clearErrors: UseFormClearErrors<IReserve>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReserveEquipInfo = (props: ReserveEquipInfoProps) => {
|
||||||
|
const { control, formState, getValues, item, setError, clearErrors } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const setErrorState = useSetRecoilState(errorStateSelector)
|
||||||
|
|
||||||
|
const [inventory, setInventory] = useState<number | null>(0)
|
||||||
|
|
||||||
|
const [compareDate, setCompareDate] = useState<{
|
||||||
|
startDate: Date
|
||||||
|
endDate: Date
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const watchStartDate = useWatch({
|
||||||
|
control,
|
||||||
|
name: 'reserveStartDate',
|
||||||
|
})
|
||||||
|
|
||||||
|
const watchEndDate = useWatch({
|
||||||
|
control,
|
||||||
|
name: 'reserveEndDate',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
let startDate = item.operationStartDate
|
||||||
|
let endDate = item.operationEndDate
|
||||||
|
if (item.reserveMeansId === 'realtime') {
|
||||||
|
startDate = item.requestStartDate
|
||||||
|
endDate = item.requestEndDate
|
||||||
|
}
|
||||||
|
setCompareDate({
|
||||||
|
startDate: convertStringToDate(startDate),
|
||||||
|
endDate: convertStringToDate(endDate),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [item])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchStartDate && compareDate) {
|
||||||
|
if (isBefore(watchStartDate, compareDate.startDate)) {
|
||||||
|
setError(
|
||||||
|
'reserveStartDate',
|
||||||
|
{
|
||||||
|
message: format(t('valid.to_be_fast.format'), [
|
||||||
|
`${t('reserve.request')} ${t('common.start_date')}`,
|
||||||
|
`${t('reserve_item.operation')}/${t('reserve_item.request')} ${t(
|
||||||
|
'reserve.period',
|
||||||
|
)}`,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{ shouldFocus: true },
|
||||||
|
)
|
||||||
|
} else if (isAfter(watchStartDate, compareDate.endDate)) {
|
||||||
|
setError(
|
||||||
|
'reserveStartDate',
|
||||||
|
{
|
||||||
|
message: format(t('valid.to_be_slow.format'), [
|
||||||
|
`${t('reserve.request')} ${t('common.start_date')}`,
|
||||||
|
`${t('reserve_item.operation')}/${t('reserve_item.request')} ${t(
|
||||||
|
'reserve.period',
|
||||||
|
)}`,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{ shouldFocus: true },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
clearErrors('reserveStartDate')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [watchStartDate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchEndDate && compareDate) {
|
||||||
|
if (isBefore(watchEndDate, compareDate.startDate)) {
|
||||||
|
setError(
|
||||||
|
'reserveEndDate',
|
||||||
|
{
|
||||||
|
message: format(t('valid.to_be_fast.format'), [
|
||||||
|
`${t('reserve.request')} ${t('common.end_date')}`,
|
||||||
|
`${t('reserve_item.operation')}/${t('reserve_item.request')} ${t(
|
||||||
|
'reserve.period',
|
||||||
|
)}`,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{ shouldFocus: true },
|
||||||
|
)
|
||||||
|
} else if (isAfter(watchEndDate, compareDate.endDate)) {
|
||||||
|
setError(
|
||||||
|
'reserveEndDate',
|
||||||
|
{
|
||||||
|
message: format(t('valid.to_be_slow.format'), [
|
||||||
|
`${t('reserve.request')} ${t('common.end_date')}`,
|
||||||
|
`${t('reserve_item.operation')}/${t('reserve_item.request')} ${t(
|
||||||
|
'reserve.period',
|
||||||
|
)}`,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{ shouldFocus: true },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
clearErrors('reserveEndDate')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [watchEndDate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchStartDate && watchEndDate) {
|
||||||
|
if (
|
||||||
|
!formState.errors.reserveStartDate &&
|
||||||
|
!formState.errors.reserveEndDate
|
||||||
|
) {
|
||||||
|
reserveService
|
||||||
|
.getInventories(
|
||||||
|
item.reserveItemId,
|
||||||
|
convertStringToDateFormat(watchStartDate),
|
||||||
|
convertStringToDateFormat(watchEndDate),
|
||||||
|
)
|
||||||
|
.then(result => {
|
||||||
|
setInventory(result.data)
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
setErrorState({ error })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [watchStartDate, watchEndDate])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ControlledDateRangePicker
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
getValues={getValues}
|
||||||
|
required={true}
|
||||||
|
startProps={{
|
||||||
|
label: `${t('reserve.request')} ${t('common.start_date')}`,
|
||||||
|
name: 'reserveStartDate',
|
||||||
|
contollerProps: {
|
||||||
|
rules: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
endProps={{
|
||||||
|
label: `${t('reserve.request')} ${t('common.end_date')}`,
|
||||||
|
name: 'reserveEndDate',
|
||||||
|
contollerProps: {
|
||||||
|
rules: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="reserveQty"
|
||||||
|
label={`${t('reserve.request')} ${t('reserve.count')}`}
|
||||||
|
defaultValue={0}
|
||||||
|
textFieldProps={{
|
||||||
|
required: true,
|
||||||
|
type: 'number',
|
||||||
|
}}
|
||||||
|
contollerProps={{
|
||||||
|
rules: {
|
||||||
|
required: true,
|
||||||
|
pattern: {
|
||||||
|
value: /^[0-9]*$/,
|
||||||
|
message: t('valid.valueAsNumber'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
help={
|
||||||
|
<FormHelperText error className={classes.help}>
|
||||||
|
({t('신청기간내 예약가능 수량')}: {inventory} )
|
||||||
|
</FormHelperText>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="reservePurposeContent"
|
||||||
|
label={`${t('reserve.request')} ${t('reserve.purpose')}`}
|
||||||
|
defaultValue={''}
|
||||||
|
textFieldProps={{
|
||||||
|
required: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ReserveEquipInfo }
|
||||||
176
frontend/admin/src/components/Reserve/ReserveInfo.tsx
Normal file
176
frontend/admin/src/components/Reserve/ReserveInfo.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import AttachList from '@components/AttachList'
|
||||||
|
import { Upload, UploadType } from '@components/Upload'
|
||||||
|
import Box from '@material-ui/core/Box'
|
||||||
|
import Card from '@material-ui/core/Card'
|
||||||
|
import CardActions from '@material-ui/core/CardActions'
|
||||||
|
import CardContent from '@material-ui/core/CardContent'
|
||||||
|
import CardHeader from '@material-ui/core/CardHeader'
|
||||||
|
import Collapse from '@material-ui/core/Collapse'
|
||||||
|
import Divider from '@material-ui/core/Divider'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
|
||||||
|
import {
|
||||||
|
IAttachmentResponse,
|
||||||
|
IReserve,
|
||||||
|
IReserveItemRelation,
|
||||||
|
ReserveFormProps,
|
||||||
|
} from '@service'
|
||||||
|
import { errorStateSelector } from '@stores'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { UseFormClearErrors, UseFormSetError } from 'react-hook-form'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSetRecoilState } from 'recoil'
|
||||||
|
import { ReserveEduInfo } from './ReserveEduInfo'
|
||||||
|
import { ReserveEquipInfo } from './ReserveEquipInfo'
|
||||||
|
import { ReserveSpaceInfo } from './ReserveSpaceInfo'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
'& .MuiInputLabel-outlined': {
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
attach: {
|
||||||
|
borderRadius: theme.spacing(0.5),
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ReserveInfoProps extends ReserveFormProps {
|
||||||
|
data?: IReserve
|
||||||
|
item: IReserveItemRelation
|
||||||
|
setError: UseFormSetError<IReserve>
|
||||||
|
clearErrors: UseFormClearErrors<IReserve>
|
||||||
|
fileProps: {
|
||||||
|
uploadRef: React.MutableRefObject<UploadType>
|
||||||
|
attachData: IAttachmentResponse[] | undefined
|
||||||
|
setAttachData: React.Dispatch<React.SetStateAction<IAttachmentResponse[]>>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const containKeys: string[] = [
|
||||||
|
'reserveItemId',
|
||||||
|
'reserveQty',
|
||||||
|
'reservePurposeContent',
|
||||||
|
'attachmentCode',
|
||||||
|
'reserveStartDate',
|
||||||
|
'reserveEndDate',
|
||||||
|
]
|
||||||
|
|
||||||
|
const ReserveInfo = (props: ReserveInfoProps) => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState,
|
||||||
|
register,
|
||||||
|
getValues,
|
||||||
|
data,
|
||||||
|
item,
|
||||||
|
setError,
|
||||||
|
clearErrors,
|
||||||
|
fileProps,
|
||||||
|
} = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState<boolean>(true)
|
||||||
|
const [errorText, setErrorText] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
// 상태관리 hook
|
||||||
|
const setErrorState = useSetRecoilState(errorStateSelector)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (formState.errors) {
|
||||||
|
const keys = Object.keys(formState.errors)
|
||||||
|
const found = keys.some(r => containKeys.includes(r))
|
||||||
|
if (keys.length > 0 && found) {
|
||||||
|
setErrorText('입력값이 잘못 되었습니다.')
|
||||||
|
} else {
|
||||||
|
setErrorText(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [formState.errors])
|
||||||
|
|
||||||
|
const handleExpandClick = () => {
|
||||||
|
setExpanded(!expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className={classes.root}>
|
||||||
|
<CardActions className={classes.header}>
|
||||||
|
<CardHeader
|
||||||
|
title={`${t('reserve')} ${t('common.information')}`}
|
||||||
|
subheader={errorText && errorText}
|
||||||
|
subheaderTypographyProps={{
|
||||||
|
color: 'error',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton onClick={handleExpandClick}>
|
||||||
|
<ExpandMoreIcon />
|
||||||
|
</IconButton>
|
||||||
|
</CardActions>
|
||||||
|
<Divider />
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<CardContent className={classes.container}>
|
||||||
|
{item?.categoryId === 'education' ? (
|
||||||
|
<ReserveEduInfo
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
totalQty={item?.totalQty}
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
) : item?.categoryId === 'space' ? (
|
||||||
|
<ReserveSpaceInfo
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
register={register}
|
||||||
|
getValues={getValues}
|
||||||
|
item={item}
|
||||||
|
setError={setError}
|
||||||
|
clearErrors={clearErrors}
|
||||||
|
/>
|
||||||
|
) : item?.categoryId === 'equipment' ? (
|
||||||
|
<ReserveEquipInfo
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
register={register}
|
||||||
|
getValues={getValues}
|
||||||
|
item={item}
|
||||||
|
setError={setError}
|
||||||
|
clearErrors={clearErrors}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Box boxShadow={1} className={classes.attach}>
|
||||||
|
<Upload
|
||||||
|
ref={fileProps?.uploadRef}
|
||||||
|
multi
|
||||||
|
attachmentCode={data?.attachmentCode}
|
||||||
|
attachData={fileProps?.attachData}
|
||||||
|
/>
|
||||||
|
{fileProps?.attachData && (
|
||||||
|
<AttachList
|
||||||
|
data={fileProps.attachData}
|
||||||
|
setData={fileProps.setAttachData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Collapse>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ReserveInfo }
|
||||||
251
frontend/admin/src/components/Reserve/ReserveInofView.tsx
Normal file
251
frontend/admin/src/components/Reserve/ReserveInofView.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import AttachList from '@components/AttachList'
|
||||||
|
import { CustomButtons, IButtonProps } from '@components/Buttons'
|
||||||
|
import DialogPopup from '@components/DialogPopup'
|
||||||
|
import DisableTextField from '@components/DisableTextField'
|
||||||
|
import ValidationAlert from '@components/EditForm/ValidationAlert'
|
||||||
|
import { convertStringToDateFormat } from '@libs/date'
|
||||||
|
import Button from '@material-ui/core/Button'
|
||||||
|
import Card from '@material-ui/core/Card'
|
||||||
|
import CardActions from '@material-ui/core/CardActions'
|
||||||
|
import CardContent from '@material-ui/core/CardContent'
|
||||||
|
import CardHeader from '@material-ui/core/CardHeader'
|
||||||
|
import Collapse from '@material-ui/core/Collapse'
|
||||||
|
import Divider from '@material-ui/core/Divider'
|
||||||
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import Paper from '@material-ui/core/Paper'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import TextField from '@material-ui/core/TextField'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
import ExpandLessIcon from '@material-ui/icons/ExpandLess'
|
||||||
|
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
|
||||||
|
import { IAttachmentResponse, IReserve } from '@service'
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
'& .MuiInputLabel-outlined': {
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
attach: {
|
||||||
|
borderRadius: theme.spacing(0.5),
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
textAlign: 'center',
|
||||||
|
backgroundColor: theme.palette.background.default,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ReserveInfoViewProps {
|
||||||
|
data: IReserve
|
||||||
|
attachData?: IAttachmentResponse[]
|
||||||
|
handleList: () => void
|
||||||
|
handleButtons: (status: string, reason?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReserveInfoView = ({
|
||||||
|
data,
|
||||||
|
attachData,
|
||||||
|
handleList,
|
||||||
|
handleButtons,
|
||||||
|
}: ReserveInfoViewProps) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const [reason, setReason] = useState<string>('')
|
||||||
|
const [reasonError, setReasonError] = useState<boolean>(false)
|
||||||
|
const [expanded, setExpanded] = useState<boolean>(true)
|
||||||
|
const [dialogOpen, setDialogOpen] = useState<boolean>(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reason.length > 0) {
|
||||||
|
setReasonError(false)
|
||||||
|
}
|
||||||
|
}, [reason])
|
||||||
|
|
||||||
|
const handleExpandClick = () => {
|
||||||
|
setExpanded(!expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDialogOpen = () => {
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (reason.length <= 0) {
|
||||||
|
setReasonError(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleButtons('cancel', reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttons = useCallback(() => {
|
||||||
|
let bs: IButtonProps[] = []
|
||||||
|
if (
|
||||||
|
data?.reserveStatusId === 'request' ||
|
||||||
|
data?.reserveStatusId === 'cancel'
|
||||||
|
) {
|
||||||
|
bs.push({
|
||||||
|
label: `${t('reserve')} ${t('common.approve')}`,
|
||||||
|
confirmMessage: `${t('reserve')} ${t('common.approve')}${t(
|
||||||
|
'common.msg.would.format',
|
||||||
|
)}`,
|
||||||
|
handleButton: () => {
|
||||||
|
handleButtons('approve')
|
||||||
|
},
|
||||||
|
completeMessage: `${t('reserve')} ${t('common.approve')}${t(
|
||||||
|
'common.msg.done.format',
|
||||||
|
)}`,
|
||||||
|
variant: 'contained',
|
||||||
|
color: 'primary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
data?.reserveStatusId === 'request' ||
|
||||||
|
data?.reserveStatusId === 'approve'
|
||||||
|
) {
|
||||||
|
bs.push({
|
||||||
|
label: `${t('reserve')} ${t('common.cancel')}`,
|
||||||
|
confirmMessage: `${t('reserve')} ${t('common.cancel')}${t(
|
||||||
|
'common.msg.would.format',
|
||||||
|
)}`,
|
||||||
|
handleButton: handleDialogOpen,
|
||||||
|
completeMessage: `${t('reserve')} ${t('common.cancel')}${t(
|
||||||
|
'common.msg.done.format',
|
||||||
|
)}`,
|
||||||
|
variant: 'contained',
|
||||||
|
color: 'secondary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
bs.push({
|
||||||
|
label: t('label.button.list'),
|
||||||
|
handleButton: handleList,
|
||||||
|
variant: 'contained',
|
||||||
|
})
|
||||||
|
|
||||||
|
return bs
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className={classes.root}>
|
||||||
|
<CardActions className={classes.header}>
|
||||||
|
<CardHeader title={`${t('reserve')} ${t('common.information')}`} />
|
||||||
|
<IconButton onClick={handleExpandClick}>
|
||||||
|
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||||
|
</IconButton>
|
||||||
|
</CardActions>
|
||||||
|
<Divider />
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<CardContent className={classes.container}>
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
{data.reserveItem.categoryId === 'education' ? null : (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<DisableTextField
|
||||||
|
label={`${t('reserve.request')} ${t('common.period')}`}
|
||||||
|
value={`${convertStringToDateFormat(
|
||||||
|
data.reserveStartDate,
|
||||||
|
)}~${convertStringToDateFormat(data.reserveEndDate)}`}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
{data.reserveItem.categoryId === 'space' ? null : (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<DisableTextField
|
||||||
|
label={
|
||||||
|
data.reserveItem.categoryId === 'education'
|
||||||
|
? `${t('reserve.request')} ${t(
|
||||||
|
'reserve.number_of_people',
|
||||||
|
)}`
|
||||||
|
: `${t('reserve.request')} ${t('reserve.count')}`
|
||||||
|
}
|
||||||
|
value={data.reserveQty}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<DisableTextField
|
||||||
|
label={`${t('reserve.request')} ${t('reserve.purpose')}`}
|
||||||
|
value={data.reservePurposeContent}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Paper className={classes.label}>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{t('common.attachment')}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
<AttachList data={attachData} readonly={true} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Collapse>
|
||||||
|
</Card>
|
||||||
|
<CustomButtons buttons={buttons()} />
|
||||||
|
<DialogPopup
|
||||||
|
id="find-dialog"
|
||||||
|
handleClose={handleDialogClose}
|
||||||
|
open={dialogOpen}
|
||||||
|
title={`${t('reserve')} ${t('common.cancel')}`}
|
||||||
|
action={{
|
||||||
|
props: {},
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={handleCancel}
|
||||||
|
variant="contained"
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{`${t('reserve')} ${t('common.cancel')}`}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleDialogClose} variant="contained">
|
||||||
|
{t('label.button.close')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
id="reason"
|
||||||
|
label={t('reserve.cancel_reason')}
|
||||||
|
type="text"
|
||||||
|
fullWidth
|
||||||
|
error={reasonError}
|
||||||
|
value={reason}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setReason(e.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{reasonError && (
|
||||||
|
<ValidationAlert message={t('reserve.msg.calcel_reason')} />
|
||||||
|
)}
|
||||||
|
</DialogPopup>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ReserveInfoView }
|
||||||
200
frontend/admin/src/components/Reserve/ReserveItemInfo.tsx
Normal file
200
frontend/admin/src/components/Reserve/ReserveItemInfo.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import DisableTextField from '@components/DisableTextField'
|
||||||
|
import { convertStringToDateFormat } from '@libs/date'
|
||||||
|
import Button from '@material-ui/core/Button'
|
||||||
|
import Card from '@material-ui/core/Card'
|
||||||
|
import CardActions from '@material-ui/core/CardActions'
|
||||||
|
import CardContent from '@material-ui/core/CardContent'
|
||||||
|
import CardHeader from '@material-ui/core/CardHeader'
|
||||||
|
import Collapse from '@material-ui/core/Collapse'
|
||||||
|
import Divider from '@material-ui/core/Divider'
|
||||||
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
import ExpandLessIcon from '@material-ui/icons/ExpandLess'
|
||||||
|
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
|
||||||
|
import { ICode, IReserveItemRelation } from '@service'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
'& .MuiInputLabel-outlined': {
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
marginLeft: theme.spacing(4),
|
||||||
|
padding: theme.spacing(0, 1),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ReserveItemInfoProps {
|
||||||
|
data: IReserveItemRelation
|
||||||
|
handleSearchItem: () => void
|
||||||
|
reserveStatus?: ICode
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReserveItemInfo = (props: ReserveItemInfoProps) => {
|
||||||
|
const { data, handleSearchItem, reserveStatus } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [expanded, setExpanded] = useState<boolean>(true)
|
||||||
|
|
||||||
|
const handleExpandClick = () => {
|
||||||
|
setExpanded(!expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={classes.root}>
|
||||||
|
<CardActions className={classes.header}>
|
||||||
|
<CardHeader title={`${t('reserve_item')} ${t('common.information')}`} />
|
||||||
|
<IconButton onClick={handleExpandClick}>
|
||||||
|
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||||
|
</IconButton>
|
||||||
|
</CardActions>
|
||||||
|
<Divider />
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<CardContent className={classes.container}>
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<DisableTextField
|
||||||
|
label={t('location')}
|
||||||
|
value={data.location.locationName}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<DisableTextField
|
||||||
|
label={t('reserve_item.type')}
|
||||||
|
value={data.categoryName}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={12}>
|
||||||
|
<DisableTextField
|
||||||
|
label={t('reserve_item.name')}
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{data.reserveItemName}
|
||||||
|
{reserveStatus ? null : (
|
||||||
|
<Button
|
||||||
|
className={classes.button}
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={handleSearchItem}
|
||||||
|
>
|
||||||
|
{`${t('reserve_item')} ${t('label.button.change')}`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
labelProps={{
|
||||||
|
xs: 4,
|
||||||
|
sm: 2,
|
||||||
|
}}
|
||||||
|
valueProps={{
|
||||||
|
xs: 8,
|
||||||
|
sm: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<DisableTextField
|
||||||
|
label={
|
||||||
|
data.categoryId === 'education'
|
||||||
|
? t('reserve.number_of_people')
|
||||||
|
: t('reserve.count')
|
||||||
|
}
|
||||||
|
value={data.totalQty}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<DisableTextField
|
||||||
|
label={t('reserve_item.selection_means')}
|
||||||
|
value={data.selectionMeansName}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<DisableTextField
|
||||||
|
label={`${t('reserve_item.operation')} ${t('reserve.period')}`}
|
||||||
|
value={`${convertStringToDateFormat(
|
||||||
|
data.operationStartDate,
|
||||||
|
)} ~ ${convertStringToDateFormat(data.operationEndDate)}`}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<DisableTextField
|
||||||
|
label={`${t('reserve_item.request')} ${t('reserve.period')}`}
|
||||||
|
value={
|
||||||
|
<Typography variant="body1" noWrap>
|
||||||
|
{`${convertStringToDateFormat(
|
||||||
|
data.requestStartDate,
|
||||||
|
'yyyy-MM-dd HH:mm',
|
||||||
|
)}
|
||||||
|
~ ${convertStringToDateFormat(
|
||||||
|
data.requestEndDate,
|
||||||
|
'yyyy-MM-dd HH:mm',
|
||||||
|
)}
|
||||||
|
`}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={12}>
|
||||||
|
<DisableTextField
|
||||||
|
label={`${t('common.free')} ${t('common.paid')}`}
|
||||||
|
value={data.isPaid ? t('common.paid') : t('common.free')}
|
||||||
|
labelProps={{
|
||||||
|
xs: 4,
|
||||||
|
sm: 2,
|
||||||
|
}}
|
||||||
|
valueProps={{
|
||||||
|
xs: 8,
|
||||||
|
sm: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{reserveStatus && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<DisableTextField
|
||||||
|
label={t('reserve.status')}
|
||||||
|
value={
|
||||||
|
reserveStatus.codeId === 'request' ? (
|
||||||
|
<Typography variant="body1" color="error">
|
||||||
|
{reserveStatus.codeName}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
reserveStatus.codeName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
labelProps={{
|
||||||
|
xs: 4,
|
||||||
|
sm: 2,
|
||||||
|
}}
|
||||||
|
valueProps={{
|
||||||
|
xs: 8,
|
||||||
|
sm: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Collapse>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ReserveItemInfo }
|
||||||
163
frontend/admin/src/components/Reserve/ReserveSpaceInfo.tsx
Normal file
163
frontend/admin/src/components/Reserve/ReserveSpaceInfo.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import {
|
||||||
|
ControlledDateRangePicker,
|
||||||
|
ControlledTextField,
|
||||||
|
} from '@components/ControlledField'
|
||||||
|
import { convertStringToDate } from '@libs/date'
|
||||||
|
import { IReserve, IReserveItemRelation, ReserveFormProps } from '@service'
|
||||||
|
import { format } from '@utils'
|
||||||
|
import isAfter from 'date-fns/isAfter'
|
||||||
|
import isBefore from 'date-fns/isBefore'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { UseFormClearErrors, UseFormSetError, useWatch } from 'react-hook-form'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface ReserveSpaceInfoProps extends ReserveFormProps {
|
||||||
|
item: IReserveItemRelation
|
||||||
|
setError: UseFormSetError<IReserve>
|
||||||
|
clearErrors: UseFormClearErrors<IReserve>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReserveSpaceInfo = (props: ReserveSpaceInfoProps) => {
|
||||||
|
const { control, formState, getValues, item, setError, clearErrors } = props
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const [compareDate, setCompareDate] = useState<{
|
||||||
|
startDate: Date
|
||||||
|
endDate: Date
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const watchStartDate = useWatch({
|
||||||
|
control,
|
||||||
|
name: 'reserveStartDate',
|
||||||
|
})
|
||||||
|
|
||||||
|
const watchEndDate = useWatch({
|
||||||
|
control,
|
||||||
|
name: 'reserveEndDate',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
let startDate = item.operationStartDate
|
||||||
|
let endDate = item.operationEndDate
|
||||||
|
if (item.reserveMeansId === 'realtime') {
|
||||||
|
startDate = item.requestStartDate
|
||||||
|
endDate = item.requestEndDate
|
||||||
|
}
|
||||||
|
setCompareDate({
|
||||||
|
startDate: convertStringToDate(startDate),
|
||||||
|
endDate: convertStringToDate(endDate),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [item])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchStartDate && compareDate) {
|
||||||
|
if (isBefore(watchStartDate, compareDate.startDate)) {
|
||||||
|
setError(
|
||||||
|
'reserveStartDate',
|
||||||
|
{
|
||||||
|
message: format(t('valid.to_be_fast.format'), [
|
||||||
|
`${t('reserve.request')} ${t('common.start_date')}`,
|
||||||
|
`${t('reserve_item.operation')}/${t('reserve_item.request')} ${t(
|
||||||
|
'reserve.period',
|
||||||
|
)}`,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{ shouldFocus: true },
|
||||||
|
)
|
||||||
|
} else if (isAfter(watchStartDate, compareDate.endDate)) {
|
||||||
|
setError(
|
||||||
|
'reserveStartDate',
|
||||||
|
{
|
||||||
|
message: format(t('valid.to_be_slow.format'), [
|
||||||
|
`${t('reserve.request')} ${t('common.start_date')}`,
|
||||||
|
`${t('reserve_item.operation')}/${t('reserve_item.request')} ${t(
|
||||||
|
'reserve.period',
|
||||||
|
)}`,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{ shouldFocus: true },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
clearErrors('reserveStartDate')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [watchStartDate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchEndDate && compareDate) {
|
||||||
|
if (isBefore(watchEndDate, compareDate.startDate)) {
|
||||||
|
setError(
|
||||||
|
'reserveEndDate',
|
||||||
|
{
|
||||||
|
message: format(t('valid.to_be_fast.format'), [
|
||||||
|
`${t('reserve.request')} ${t('common.end_date')}`,
|
||||||
|
`${t('reserve_item.operation')}/${t('reserve_item.request')} ${t(
|
||||||
|
'reserve.period',
|
||||||
|
)}`,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{ shouldFocus: true },
|
||||||
|
)
|
||||||
|
} else if (isAfter(watchEndDate, compareDate.endDate)) {
|
||||||
|
setError(
|
||||||
|
'reserveEndDate',
|
||||||
|
{
|
||||||
|
message: format(t('valid.to_be_slow.format'), [
|
||||||
|
`${t('reserve.request')} ${t('common.end_date')}`,
|
||||||
|
`${t('reserve_item.operation')}/${t('reserve_item.request')} ${t(
|
||||||
|
'reserve.period',
|
||||||
|
)}`,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{ shouldFocus: true },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
clearErrors('reserveEndDate')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [watchEndDate])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ControlledDateRangePicker
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
getValues={getValues}
|
||||||
|
required={true}
|
||||||
|
startProps={{
|
||||||
|
label: `${t('reserve.request')} ${t('common.start_date')}`,
|
||||||
|
name: 'reserveStartDate',
|
||||||
|
minDate: new Date(),
|
||||||
|
contollerProps: {
|
||||||
|
rules: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
endProps={{
|
||||||
|
label: `${t('reserve.request')} ${t('common.end_date')}`,
|
||||||
|
name: 'reserveEndDate',
|
||||||
|
contollerProps: {
|
||||||
|
rules: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="reservePurposeContent"
|
||||||
|
label={`${t('reserve.request')} ${t('reserve.purpose')}`}
|
||||||
|
defaultValue={''}
|
||||||
|
textFieldProps={{
|
||||||
|
required: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ReserveSpaceInfo }
|
||||||
4
frontend/admin/src/components/Reserve/index.tsx
Normal file
4
frontend/admin/src/components/Reserve/index.tsx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './ReserveItemInfo'
|
||||||
|
export * from './ReserveInfo'
|
||||||
|
export * from './ReserveClientInfo'
|
||||||
|
export * from './ReserveInofView'
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import {
|
||||||
|
ControlledRadioField,
|
||||||
|
ControlledTextField,
|
||||||
|
} from '@components/ControlledField'
|
||||||
|
import Card from '@material-ui/core/Card'
|
||||||
|
import CardActions from '@material-ui/core/CardActions'
|
||||||
|
import CardContent from '@material-ui/core/CardContent'
|
||||||
|
import CardHeader from '@material-ui/core/CardHeader'
|
||||||
|
import Collapse from '@material-ui/core/Collapse'
|
||||||
|
import Divider from '@material-ui/core/Divider'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
|
||||||
|
import { ICode, ReserveItemFormProps } from '@service'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
'& .MuiInputLabel-outlined': {
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ReserveItemAdditionalProps extends ReserveItemFormProps {
|
||||||
|
targets: ICode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const containKeys: string[] = [
|
||||||
|
'purpose',
|
||||||
|
'address',
|
||||||
|
'targetId',
|
||||||
|
'excluded',
|
||||||
|
'homepage',
|
||||||
|
'contact',
|
||||||
|
]
|
||||||
|
|
||||||
|
const ReserveItemAdditional = (props: ReserveItemAdditionalProps) => {
|
||||||
|
const { control, formState, targets } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const [expanded, setExpanded] = useState<boolean>(true)
|
||||||
|
const [errorText, setErrorText] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (formState.errors) {
|
||||||
|
const keys = Object.keys(formState.errors)
|
||||||
|
const found = keys.some(r => containKeys.includes(r))
|
||||||
|
if (keys.length > 0 && found) {
|
||||||
|
setErrorText('입력값이 잘못 되었습니다.')
|
||||||
|
} else {
|
||||||
|
setErrorText(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [formState.errors])
|
||||||
|
|
||||||
|
const handleExpandClick = () => {
|
||||||
|
setExpanded(!expanded)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className={classes.root}>
|
||||||
|
<CardActions className={classes.header}>
|
||||||
|
<CardHeader
|
||||||
|
title={t('reserve_item.add_information')}
|
||||||
|
subheader={errorText && errorText}
|
||||||
|
subheaderTypographyProps={{
|
||||||
|
color: 'error',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton onClick={handleExpandClick}>
|
||||||
|
<ExpandMoreIcon />
|
||||||
|
</IconButton>
|
||||||
|
</CardActions>
|
||||||
|
<Divider />
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<CardContent className={classes.container}>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="purpose"
|
||||||
|
label={t('reserve_item.purpose')}
|
||||||
|
defaultValue={''}
|
||||||
|
contollerProps={{
|
||||||
|
rules: {
|
||||||
|
maxLength: 4000,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
textFieldProps={{
|
||||||
|
required: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="address"
|
||||||
|
label={t('common.address')}
|
||||||
|
defaultValue={''}
|
||||||
|
contollerProps={{
|
||||||
|
rules: {
|
||||||
|
maxLength: 500,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
textFieldProps={{
|
||||||
|
required: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ControlledRadioField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="targetId"
|
||||||
|
label={t('reserve_item.target')}
|
||||||
|
defaultValue={'no-limit'}
|
||||||
|
requried={true}
|
||||||
|
data={{
|
||||||
|
idkey: 'codeId',
|
||||||
|
namekey: 'codeName',
|
||||||
|
data: targets,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="excluded"
|
||||||
|
label={t('reserve_item.excluded')}
|
||||||
|
defaultValue={''}
|
||||||
|
contollerProps={{
|
||||||
|
rules: {
|
||||||
|
maxLength: 2000,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
textFieldProps={{
|
||||||
|
required: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="homepage"
|
||||||
|
label={t('common.home_page_address')}
|
||||||
|
defaultValue={''}
|
||||||
|
contollerProps={{
|
||||||
|
rules: {
|
||||||
|
maxLength: 500,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
textFieldProps={{
|
||||||
|
required: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="contact"
|
||||||
|
label={t('reserve_item.contact')}
|
||||||
|
defaultValue={''}
|
||||||
|
contollerProps={{
|
||||||
|
rules: {
|
||||||
|
maxLength: 50,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
textFieldProps={{
|
||||||
|
required: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Collapse>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ReserveItemAdditional }
|
||||||
352
frontend/admin/src/components/ReserveItem/ReserveItemBasic.tsx
Normal file
352
frontend/admin/src/components/ReserveItem/ReserveItemBasic.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import {
|
||||||
|
ControlledDateRangePicker,
|
||||||
|
ControlledRadioField,
|
||||||
|
ControlledSwitchField,
|
||||||
|
ControlledTextField,
|
||||||
|
} from '@components/ControlledField'
|
||||||
|
import Box from '@material-ui/core/Box'
|
||||||
|
import Card from '@material-ui/core/Card'
|
||||||
|
import CardActions from '@material-ui/core/CardActions'
|
||||||
|
import CardContent from '@material-ui/core/CardContent'
|
||||||
|
import CardHeader from '@material-ui/core/CardHeader'
|
||||||
|
import Collapse from '@material-ui/core/Collapse'
|
||||||
|
import Divider from '@material-ui/core/Divider'
|
||||||
|
import FormHelperText from '@material-ui/core/FormHelperText'
|
||||||
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import MenuItem from '@material-ui/core/MenuItem'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import Switch from '@material-ui/core/Switch'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
|
||||||
|
import { ICode, ILocation, IReserveItem, ReserveItemFormProps } from '@service'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Controller, useWatch } from 'react-hook-form'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ReserveItemMethod } from './ReserveItemMethod'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
help: {
|
||||||
|
marginLeft: theme.spacing(2),
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
switch: {
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'start',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.23)',
|
||||||
|
borderRadius: theme.spacing(0.5),
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
margin: theme.spacing(1, 0),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ReserveItemBasicProps extends ReserveItemFormProps {
|
||||||
|
data: IReserveItem
|
||||||
|
locations: ILocation[]
|
||||||
|
categories: ICode[]
|
||||||
|
reserveMethods: ICode[]
|
||||||
|
reserveMeans: ICode[]
|
||||||
|
selectionMeans: ICode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const containKeys: string[] = [
|
||||||
|
'locationId',
|
||||||
|
'categoryId',
|
||||||
|
'reserveItemId',
|
||||||
|
'totalQty',
|
||||||
|
'operationStartDate',
|
||||||
|
'operationEndDate',
|
||||||
|
'reserveMethodId',
|
||||||
|
'reserveMeansId',
|
||||||
|
'requestStartDate',
|
||||||
|
'requestEndDate',
|
||||||
|
'isPeriod',
|
||||||
|
'periodMaxCount',
|
||||||
|
'externalUrl',
|
||||||
|
'selectionMeansId',
|
||||||
|
'isFree',
|
||||||
|
'usageCost',
|
||||||
|
'isUse',
|
||||||
|
]
|
||||||
|
|
||||||
|
const ReserveItemBasic = (props: ReserveItemBasicProps) => {
|
||||||
|
const {
|
||||||
|
getValues,
|
||||||
|
control,
|
||||||
|
formState,
|
||||||
|
data,
|
||||||
|
locations,
|
||||||
|
categories,
|
||||||
|
reserveMethods,
|
||||||
|
reserveMeans,
|
||||||
|
selectionMeans,
|
||||||
|
} = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [expanded, setExpanded] = useState<boolean>(true)
|
||||||
|
const [errorText, setErrorText] = useState<string | undefined>(undefined)
|
||||||
|
const [openMethod, setOpenMethod] = useState<boolean>(false)
|
||||||
|
const [openCost, setOpenCost] = useState<boolean>(false)
|
||||||
|
|
||||||
|
const watchReserveMethod = useWatch({
|
||||||
|
control,
|
||||||
|
name: 'reserveMethodId',
|
||||||
|
})
|
||||||
|
const watchFree = useWatch({
|
||||||
|
control,
|
||||||
|
name: 'isPaid',
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
if (formState.errors) {
|
||||||
|
const keys = Object.keys(formState.errors)
|
||||||
|
const found = keys.some(r => containKeys.includes(r))
|
||||||
|
if (keys.length > 0 && found) {
|
||||||
|
setErrorText('입력값이 잘못 되었습니다.')
|
||||||
|
} else {
|
||||||
|
setErrorText(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [formState.errors])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchReserveMethod === 'internet') {
|
||||||
|
setOpenMethod(true)
|
||||||
|
} else {
|
||||||
|
setOpenMethod(false)
|
||||||
|
}
|
||||||
|
}, [watchReserveMethod])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpenCost(watchFree)
|
||||||
|
}, [watchFree])
|
||||||
|
|
||||||
|
const handleExpandClick = () => {
|
||||||
|
setExpanded(!expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className={classes.root}>
|
||||||
|
<CardActions className={classes.header}>
|
||||||
|
<CardHeader
|
||||||
|
title={`${t('common.basic')} ${t('common.information')}`}
|
||||||
|
subheader={errorText && errorText}
|
||||||
|
subheaderTypographyProps={{
|
||||||
|
color: 'error',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton onClick={handleExpandClick}>
|
||||||
|
<ExpandMoreIcon />
|
||||||
|
</IconButton>
|
||||||
|
</CardActions>
|
||||||
|
<Divider />
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<CardContent className={classes.container}>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="locationId"
|
||||||
|
label={t('location')}
|
||||||
|
isSelect={true}
|
||||||
|
defaultValue={1}
|
||||||
|
textFieldProps={{
|
||||||
|
required: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locations.map(value => (
|
||||||
|
<MenuItem
|
||||||
|
key={`location-${value.locationId}`}
|
||||||
|
value={value.locationId}
|
||||||
|
>
|
||||||
|
{value.locationName}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</ControlledTextField>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="categoryId"
|
||||||
|
label={t('reserve_item.type')}
|
||||||
|
isSelect={true}
|
||||||
|
defaultValue={''}
|
||||||
|
textFieldProps={{
|
||||||
|
required: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categories.map(value => (
|
||||||
|
<MenuItem key={`category-${value.codeId}`} value={value.codeId}>
|
||||||
|
{value.codeName}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</ControlledTextField>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="reserveItemName"
|
||||||
|
label={t('reserve_item.name')}
|
||||||
|
defaultValue={''}
|
||||||
|
textFieldProps={{
|
||||||
|
required: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="totalQty"
|
||||||
|
label={`${t('reserve.count')}/${t('reserve.number_of_people')}`}
|
||||||
|
defaultValue={''}
|
||||||
|
textFieldProps={{
|
||||||
|
fullWidth: false,
|
||||||
|
required: true,
|
||||||
|
}}
|
||||||
|
help={
|
||||||
|
<FormHelperText className={classes.help}>
|
||||||
|
* {t('reserve_titem.msg.help_period')}
|
||||||
|
</FormHelperText>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ControlledDateRangePicker
|
||||||
|
getValues={getValues}
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
required={true}
|
||||||
|
startProps={{
|
||||||
|
label: `${t('reserve_item.operation')} ${t(
|
||||||
|
'common.start_date',
|
||||||
|
)}`,
|
||||||
|
name: 'operationStartDate',
|
||||||
|
contollerProps: {
|
||||||
|
rules: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
endProps={{
|
||||||
|
label: `${t('reserve_item.operation')} ${t('common.end_date')}`,
|
||||||
|
name: 'operationEndDate',
|
||||||
|
contollerProps: {
|
||||||
|
rules: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ControlledRadioField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="reserveMethodId"
|
||||||
|
label={t('reserve_item.reserve_method')}
|
||||||
|
defaultValue={''}
|
||||||
|
requried={true}
|
||||||
|
data={{
|
||||||
|
idkey: 'codeId',
|
||||||
|
namekey: 'codeName',
|
||||||
|
data: reserveMethods,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{openMethod && (
|
||||||
|
<ReserveItemMethod
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
getValues={getValues}
|
||||||
|
reserveMeans={reserveMeans}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ControlledRadioField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="selectionMeansId"
|
||||||
|
label={t('reserve_item.selection_means')}
|
||||||
|
defaultValue={''}
|
||||||
|
requried
|
||||||
|
data={{
|
||||||
|
idkey: 'codeId',
|
||||||
|
namekey: 'codeName',
|
||||||
|
data: selectionMeans,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box className={classes.switch}>
|
||||||
|
<Typography component="div">
|
||||||
|
<Grid
|
||||||
|
component="label"
|
||||||
|
container
|
||||||
|
alignItems="center"
|
||||||
|
spacing={1}
|
||||||
|
>
|
||||||
|
<Grid item>{t('common.free')}</Grid>
|
||||||
|
<Grid item>
|
||||||
|
<Controller
|
||||||
|
name="isPaid"
|
||||||
|
control={control}
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field: { onChange, ref, value } }) => (
|
||||||
|
<Switch
|
||||||
|
inputProps={{ 'aria-label': 'secondary checkbox' }}
|
||||||
|
onChange={onChange}
|
||||||
|
inputRef={ref}
|
||||||
|
checked={value}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item>{t('common.paid')}</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{openCost && (
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="usageCost"
|
||||||
|
label={t('reserve_item.usage_fee')}
|
||||||
|
defaultValue={0}
|
||||||
|
textFieldProps={{
|
||||||
|
required: true,
|
||||||
|
type: 'number',
|
||||||
|
}}
|
||||||
|
contollerProps={{
|
||||||
|
rules: {
|
||||||
|
required: true,
|
||||||
|
pattern: {
|
||||||
|
value: /^[0-9]*$/,
|
||||||
|
message: t('valid.valueAsNumber'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ControlledSwitchField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
label={t('common.use_at')}
|
||||||
|
name="isUse"
|
||||||
|
contollerProps={{
|
||||||
|
defaultValue: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Collapse>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ReserveItemBasic }
|
||||||
131
frontend/admin/src/components/ReserveItem/ReserveItemManager.tsx
Normal file
131
frontend/admin/src/components/ReserveItem/ReserveItemManager.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { ControlledTextField } from '@components/ControlledField'
|
||||||
|
import Card from '@material-ui/core/Card'
|
||||||
|
import CardActions from '@material-ui/core/CardActions'
|
||||||
|
import CardContent from '@material-ui/core/CardContent'
|
||||||
|
import CardHeader from '@material-ui/core/CardHeader'
|
||||||
|
import Collapse from '@material-ui/core/Collapse'
|
||||||
|
import Divider from '@material-ui/core/Divider'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
|
||||||
|
import { ReserveItemFormProps } from '@service'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
'& .MuiInputLabel-outlined': {
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const containKeys: string[] = ['managerDept', 'managerName', 'managerContact']
|
||||||
|
|
||||||
|
interface ReserveItemManagerProps extends ReserveItemFormProps {}
|
||||||
|
|
||||||
|
const ReserveItemManager = (props: ReserveItemManagerProps) => {
|
||||||
|
const { control, formState } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [expanded, setExpanded] = useState<boolean>(true)
|
||||||
|
|
||||||
|
const [errorText, setErrorText] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (formState.errors) {
|
||||||
|
const keys = Object.keys(formState.errors)
|
||||||
|
const found = keys.some(r => containKeys.includes(r))
|
||||||
|
if (keys.length > 0 && found) {
|
||||||
|
setErrorText('입력값이 잘못 되었습니다.')
|
||||||
|
} else {
|
||||||
|
setErrorText(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [formState.errors])
|
||||||
|
|
||||||
|
const handleExpandClick = () => {
|
||||||
|
setExpanded(!expanded)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className={classes.root}>
|
||||||
|
<CardActions className={classes.header}>
|
||||||
|
<CardHeader
|
||||||
|
title={`${t('reserve_item.manager')} ${t('common.information')}`}
|
||||||
|
subheader={errorText && errorText}
|
||||||
|
subheaderTypographyProps={{
|
||||||
|
color: 'error',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton onClick={handleExpandClick}>
|
||||||
|
<ExpandMoreIcon />
|
||||||
|
</IconButton>
|
||||||
|
</CardActions>
|
||||||
|
<Divider />
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<CardContent className={classes.container}>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="managerDept"
|
||||||
|
label={`${t('reserve_item.manager')} ${t('reserve_item.dept')}`}
|
||||||
|
defaultValue={''}
|
||||||
|
contollerProps={{
|
||||||
|
rules: {
|
||||||
|
maxLength: 200,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
textFieldProps={{
|
||||||
|
required: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="managerName"
|
||||||
|
label={`${t('reserve_item.manager')} ${t('label.title.name')}`}
|
||||||
|
defaultValue={''}
|
||||||
|
contollerProps={{
|
||||||
|
rules: {
|
||||||
|
maxLength: 200,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
textFieldProps={{
|
||||||
|
required: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="managerContact"
|
||||||
|
label={`${t('reserve_item.manager')} ${t('common.contact')}`}
|
||||||
|
defaultValue={''}
|
||||||
|
contollerProps={{
|
||||||
|
rules: {
|
||||||
|
maxLength: 50,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
textFieldProps={{
|
||||||
|
required: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Collapse>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ReserveItemManager }
|
||||||
146
frontend/admin/src/components/ReserveItem/ReserveItemMethod.tsx
Normal file
146
frontend/admin/src/components/ReserveItem/ReserveItemMethod.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import { useWatch } from 'react-hook-form'
|
||||||
|
import { ICode, ReserveItemFormProps } from '@service'
|
||||||
|
import {
|
||||||
|
ControlledDateRangePicker,
|
||||||
|
ControlledRadioField,
|
||||||
|
ControlledSwitchField,
|
||||||
|
ControlledTextField,
|
||||||
|
} from '@components/ControlledField'
|
||||||
|
|
||||||
|
interface ReserveItemMethodProps extends ReserveItemFormProps {
|
||||||
|
reserveMeans: ICode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReserveItemMethod = (props: ReserveItemMethodProps) => {
|
||||||
|
const { control, formState, getValues, reserveMeans } = props
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [isExternal, setIsExternal] = useState<boolean | null>(null)
|
||||||
|
const watchReserveMeans = useWatch({
|
||||||
|
control,
|
||||||
|
name: 'reserveMeansId',
|
||||||
|
})
|
||||||
|
|
||||||
|
const [open, setOpen] = useState<boolean>(false)
|
||||||
|
const watchPeriod = useWatch({
|
||||||
|
control,
|
||||||
|
name: 'isPeriod',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(watchPeriod)
|
||||||
|
}, [watchPeriod])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchReserveMeans === 'external') {
|
||||||
|
setIsExternal(true)
|
||||||
|
} else {
|
||||||
|
setIsExternal(false)
|
||||||
|
}
|
||||||
|
}, [watchReserveMeans])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ControlledRadioField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="reserveMeansId"
|
||||||
|
label={'인터넷 예약 구분'}
|
||||||
|
defaultValue={''}
|
||||||
|
requried
|
||||||
|
data={{
|
||||||
|
idkey: 'codeId',
|
||||||
|
namekey: 'codeName',
|
||||||
|
data: reserveMeans,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Boolean(isExternal) === false && (
|
||||||
|
<>
|
||||||
|
<ControlledDateRangePicker
|
||||||
|
getValues={getValues}
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
required={true}
|
||||||
|
format="yyyy-MM-dd HH:mm"
|
||||||
|
startProps={{
|
||||||
|
label: '예약신청시작일시',
|
||||||
|
name: 'requestStartDate',
|
||||||
|
contollerProps: {
|
||||||
|
rules: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showTimeSelect: true,
|
||||||
|
}}
|
||||||
|
endProps={{
|
||||||
|
label: '예약신청종료일시',
|
||||||
|
name: 'requestEndDate',
|
||||||
|
contollerProps: {
|
||||||
|
rules: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showTimeSelect: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ControlledSwitchField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
label={'기간 지정 가능여부'}
|
||||||
|
name="isPeriod"
|
||||||
|
contollerProps={{
|
||||||
|
defaultValue: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="periodMaxCount"
|
||||||
|
label={'최대예약가능일수'}
|
||||||
|
defaultValue={0}
|
||||||
|
textFieldProps={{
|
||||||
|
required: true,
|
||||||
|
type: 'number',
|
||||||
|
}}
|
||||||
|
contollerProps={{
|
||||||
|
rules: {
|
||||||
|
required: true,
|
||||||
|
maxLength: 3,
|
||||||
|
pattern: {
|
||||||
|
value: /^[0-9]*$/,
|
||||||
|
message: t('valid.valueAsNumber'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isExternal && (
|
||||||
|
<ControlledTextField
|
||||||
|
control={control}
|
||||||
|
formState={formState}
|
||||||
|
name="externalUrl"
|
||||||
|
label={'외부링크URL'}
|
||||||
|
defaultValue={''}
|
||||||
|
textFieldProps={{
|
||||||
|
required: true,
|
||||||
|
}}
|
||||||
|
contollerProps={{
|
||||||
|
rules: {
|
||||||
|
required: true,
|
||||||
|
maxLength: 500,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ReserveItemMethod }
|
||||||
3
frontend/admin/src/components/ReserveItem/index.tsx
Normal file
3
frontend/admin/src/components/ReserveItem/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './ReserveItemBasic'
|
||||||
|
export * from './ReserveItemAdditional'
|
||||||
|
export * from './ReserveItemManager'
|
||||||
187
frontend/admin/src/components/Search/index.tsx
Normal file
187
frontend/admin/src/components/Search/index.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import React, { createRef, useEffect, useState } from 'react'
|
||||||
|
import { makeStyles, createStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import Box from '@material-ui/core/Box'
|
||||||
|
import TextField from '@material-ui/core/TextField'
|
||||||
|
import MenuItem from '@material-ui/core/MenuItem'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import SearchIcon from '@material-ui/icons/Search'
|
||||||
|
import Fab from '@material-ui/core/Fab'
|
||||||
|
import AddIcon from '@material-ui/icons/Add'
|
||||||
|
import { conditionAtom, conditionSelector, conditionValue } from '@stores'
|
||||||
|
import { useRecoilValue, useSetRecoilState } from 'recoil'
|
||||||
|
|
||||||
|
// styles
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
'& .MuiOutlinedInput-input': {
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
textAlign: 'center',
|
||||||
|
width: '15vw',
|
||||||
|
minWidth: 80,
|
||||||
|
maxWidth: 150,
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
backgroundColor: theme.palette.background.default,
|
||||||
|
},
|
||||||
|
fab: {
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 조회조건 타입
|
||||||
|
export interface ICondition {
|
||||||
|
keywordType: string
|
||||||
|
keyword: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조회조건 select 아이템 타입
|
||||||
|
export interface IKeywordType {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조회조건 컴포넌트 props
|
||||||
|
export interface ISearchProp {
|
||||||
|
keywordTypeItems: IKeywordType[] // 조회조건 select items
|
||||||
|
handleSearch: () => void // 조회 시
|
||||||
|
handleRegister?: () => void // 등록 시
|
||||||
|
conditionKey: string // 조회조건 상태값을 관리할 키 값 (e.g. 이용약관관리 -> policy)
|
||||||
|
isNotWrapper?: boolean
|
||||||
|
customKeyword?: conditionValue
|
||||||
|
conditionNodes?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const Search = (props: ISearchProp) => {
|
||||||
|
const {
|
||||||
|
keywordTypeItems,
|
||||||
|
handleSearch,
|
||||||
|
handleRegister,
|
||||||
|
conditionKey,
|
||||||
|
customKeyword,
|
||||||
|
isNotWrapper,
|
||||||
|
conditionNodes,
|
||||||
|
} = props
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
// 조회조건에 대한 키(conditionKey)로 각 기능에서 조회조건 상태값을 관리한다.
|
||||||
|
const setValue = useSetRecoilState(conditionSelector(conditionKey))
|
||||||
|
const conditionState = useRecoilValue(conditionAtom(conditionKey))
|
||||||
|
const [keywordTypeState, setKeywordTypeState] = useState<string>('')
|
||||||
|
const inputRef = createRef<HTMLInputElement>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (conditionState) {
|
||||||
|
setKeywordTypeState(conditionState.keywordType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keywordTypeItems.length > 0) {
|
||||||
|
setKeywordTypeState(keywordTypeItems[0].key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}, [conditionState, keywordTypeItems])
|
||||||
|
|
||||||
|
// 조회 시 조회조건 상태값 저장 후 부모컴포넌트의 조회 함수를 call한다.
|
||||||
|
const search = () => {
|
||||||
|
setValue({
|
||||||
|
...conditionState,
|
||||||
|
keywordType: keywordTypeState,
|
||||||
|
keyword: inputRef.current?.value,
|
||||||
|
...customKeyword,
|
||||||
|
})
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조회조건 select onchange
|
||||||
|
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setKeywordTypeState(event.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조회조건 input에서 enter키 눌렀을 경우 조회
|
||||||
|
const onKeyPress = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
search()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조회 버튼 클릭
|
||||||
|
const onClickSearch = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
search()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 등록 버튼 클릭
|
||||||
|
const onClickAdd = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
handleRegister()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<Box display="flex" flexDirection="row" justifyContent="flex-end">
|
||||||
|
{conditionNodes && conditionNodes}
|
||||||
|
<Box className={classes.select}>
|
||||||
|
<TextField
|
||||||
|
id="filled-select-currency"
|
||||||
|
select
|
||||||
|
value={keywordTypeState}
|
||||||
|
onChange={onChange}
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{keywordTypeItems.map(option => (
|
||||||
|
<MenuItem key={option.key} value={option.key}>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Box>
|
||||||
|
<Box width="auto" className={classes.search}>
|
||||||
|
<TextField
|
||||||
|
inputRef={inputRef}
|
||||||
|
placeholder="Search..."
|
||||||
|
inputProps={{ 'aria-label': 'search' }}
|
||||||
|
variant="outlined"
|
||||||
|
onKeyPress={onKeyPress}
|
||||||
|
defaultValue={conditionState ? conditionState.keyword : ''}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
className={classes.iconButton}
|
||||||
|
aria-label="search"
|
||||||
|
color="primary"
|
||||||
|
onClick={onClickSearch}
|
||||||
|
>
|
||||||
|
<SearchIcon />
|
||||||
|
</IconButton>
|
||||||
|
{handleRegister && (
|
||||||
|
<Fab
|
||||||
|
color="primary"
|
||||||
|
aria-label="add"
|
||||||
|
className={classes.fab}
|
||||||
|
size="small"
|
||||||
|
onClick={onClickAdd}
|
||||||
|
>
|
||||||
|
<AddIcon />
|
||||||
|
</Fab>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Search
|
||||||
44
frontend/admin/src/components/Table/CustomDataGrid.tsx
Normal file
44
frontend/admin/src/components/Table/CustomDataGrid.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
GRID_PAGE_SIZE,
|
||||||
|
GRID_ROWS_PER_PAGE_OPTION,
|
||||||
|
GRID_ROW_HEIGHT,
|
||||||
|
} from '@constants'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import { DataGrid, DataGridProps } from '@material-ui/data-grid'
|
||||||
|
import * as React from 'react'
|
||||||
|
import DataGridPagination from './DataGridPagination'
|
||||||
|
|
||||||
|
export interface IDataGridProps extends DataGridProps {}
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
'& .hover': {
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#1a3e72',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function CustomDataGrid(props: IDataGridProps) {
|
||||||
|
const { columns, rows, pageSize, rowsPerPageOptions, rowHeight, getRowId } =
|
||||||
|
props
|
||||||
|
const classes = useStyles()
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<DataGrid
|
||||||
|
{...props}
|
||||||
|
rows={rows || []}
|
||||||
|
columns={columns}
|
||||||
|
rowHeight={rowHeight || GRID_ROW_HEIGHT}
|
||||||
|
pageSize={pageSize || GRID_PAGE_SIZE}
|
||||||
|
rowsPerPageOptions={rowsPerPageOptions || GRID_ROWS_PER_PAGE_OPTION}
|
||||||
|
autoHeight
|
||||||
|
pagination
|
||||||
|
components={{ Pagination: DataGridPagination }}
|
||||||
|
getRowId={getRowId || (r => r.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
frontend/admin/src/components/Table/DataGridPagination.tsx
Normal file
24
frontend/admin/src/components/Table/DataGridPagination.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useGridSlotComponentProps } from '@material-ui/data-grid'
|
||||||
|
import Pagination, { PaginationProps } from '@material-ui/lab/Pagination'
|
||||||
|
import PaginationItem from '@material-ui/lab/PaginationItem'
|
||||||
|
|
||||||
|
export default function DataGridPagination(props: PaginationProps) {
|
||||||
|
const { state, apiRef } = useGridSlotComponentProps()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pagination
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
shape="rounded"
|
||||||
|
page={state.pagination.page + 1}
|
||||||
|
count={state.pagination.pageCount}
|
||||||
|
showFirstButton={true}
|
||||||
|
showLastButton={true}
|
||||||
|
// @ts-expect-error
|
||||||
|
renderItem={item => <PaginationItem {...item} disableRipple />}
|
||||||
|
onChange={(event, value) => apiRef.current.setPage(value - 1)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
frontend/admin/src/components/Table/Pagination.tsx
Normal file
90
frontend/admin/src/components/Table/Pagination.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import FirstPageIcon from '@material-ui/icons/FirstPage'
|
||||||
|
import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft'
|
||||||
|
import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight'
|
||||||
|
import LastPageIcon from '@material-ui/icons/LastPage'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexShrink: 0,
|
||||||
|
marginLeft: theme.spacing(2.5),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface TablePaginationActionsProps {
|
||||||
|
count: number
|
||||||
|
page: number
|
||||||
|
rowsPerPage: number
|
||||||
|
onChangePage: (
|
||||||
|
event: React.MouseEvent<HTMLButtonElement>,
|
||||||
|
newPage: number,
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TablePaginationActions(
|
||||||
|
props: TablePaginationActionsProps,
|
||||||
|
) {
|
||||||
|
const classes = useStyles()
|
||||||
|
const { count, page, rowsPerPage, onChangePage } = props
|
||||||
|
|
||||||
|
const handleFirstPageButtonClick = (
|
||||||
|
event: React.MouseEvent<HTMLButtonElement>,
|
||||||
|
) => {
|
||||||
|
onChangePage(event, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBackButtonClick = (
|
||||||
|
event: React.MouseEvent<HTMLButtonElement>,
|
||||||
|
) => {
|
||||||
|
onChangePage(event, page - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNextButtonClick = (
|
||||||
|
event: React.MouseEvent<HTMLButtonElement>,
|
||||||
|
) => {
|
||||||
|
onChangePage(event, page + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLastPageButtonClick = (
|
||||||
|
event: React.MouseEvent<HTMLButtonElement>,
|
||||||
|
) => {
|
||||||
|
onChangePage(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleFirstPageButtonClick}
|
||||||
|
disabled={page === 0}
|
||||||
|
aria-label="first page"
|
||||||
|
>
|
||||||
|
<FirstPageIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleBackButtonClick}
|
||||||
|
disabled={page === 0}
|
||||||
|
aria-label="previous page"
|
||||||
|
>
|
||||||
|
<KeyboardArrowLeft />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleNextButtonClick}
|
||||||
|
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||||
|
aria-label="next page"
|
||||||
|
>
|
||||||
|
<KeyboardArrowRight />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleLastPageButtonClick}
|
||||||
|
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||||
|
aria-label="last page"
|
||||||
|
>
|
||||||
|
<LastPageIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
frontend/admin/src/components/Tabs/Horizontal.tsx
Normal file
46
frontend/admin/src/components/Tabs/Horizontal.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import Paper from '@material-ui/core/Paper'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import Tabs, { TabsProps } from '@material-ui/core/Tabs'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
background: theme.palette.background.default,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface HorizontalTabsProps extends TabsProps {
|
||||||
|
tabs: React.ReactNode
|
||||||
|
init: string | number
|
||||||
|
handleTab: (value: string | number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const HorizontalTabs = (props: HorizontalTabsProps) => {
|
||||||
|
const { tabs, init, handleTab, ...rest } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
const [value, setValue] = useState<string | number>(init)
|
||||||
|
|
||||||
|
const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => {
|
||||||
|
handleTab(newValue)
|
||||||
|
setValue(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className={classes.root}>
|
||||||
|
<Tabs
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
indicatorColor="primary"
|
||||||
|
textColor="primary"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{tabs}
|
||||||
|
</Tabs>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HorizontalTabs }
|
||||||
1
frontend/admin/src/components/Tabs/index.tsx
Normal file
1
frontend/admin/src/components/Tabs/index.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './Horizontal'
|
||||||
116
frontend/admin/src/components/Upload/FileList.tsx
Normal file
116
frontend/admin/src/components/Upload/FileList.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import React, { useContext, useEffect, useState } from 'react'
|
||||||
|
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'
|
||||||
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
import List from '@material-ui/core/List'
|
||||||
|
import ListItem from '@material-ui/core/ListItem'
|
||||||
|
import ListItemAvatar from '@material-ui/core/ListItemAvatar'
|
||||||
|
import Avatar from '@material-ui/core/Avatar'
|
||||||
|
import FolderIcon from '@material-ui/icons/Folder'
|
||||||
|
import ListItemText from '@material-ui/core/ListItemText'
|
||||||
|
import { IFile } from '@service'
|
||||||
|
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import DeleteIcon from '@material-ui/icons/Delete'
|
||||||
|
import { formatBytes } from '@utils'
|
||||||
|
import produce from 'immer'
|
||||||
|
import { FileContext } from '.'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
marginTop: '1px',
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
padding: theme.spacing(0),
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
padding: theme.spacing(1, 6, 1, 1),
|
||||||
|
},
|
||||||
|
pd0: {
|
||||||
|
padding: theme.spacing(0),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface IFileList {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileList = () => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const { selectedFiles, setSelectedFilesHandler } = useContext(FileContext)
|
||||||
|
const [fileList, setFileList] = useState<IFileList[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let list: IFileList[] = []
|
||||||
|
|
||||||
|
for (const key in selectedFiles) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(selectedFiles, key)) {
|
||||||
|
const item = selectedFiles[key]
|
||||||
|
list.push({
|
||||||
|
key: item.key,
|
||||||
|
name: item.file.name,
|
||||||
|
size: item.file.size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFileList(list)
|
||||||
|
}, [selectedFiles])
|
||||||
|
|
||||||
|
const handleDelete = (
|
||||||
|
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||||
|
key: string,
|
||||||
|
) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const index = selectedFiles.findIndex(item => item.key === key)
|
||||||
|
const newFiles: IFile[] = produce(selectedFiles, draft => {
|
||||||
|
draft.splice(index, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
setSelectedFilesHandler(newFiles)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<Grid container>
|
||||||
|
<Grid item>
|
||||||
|
<div>
|
||||||
|
{fileList && (
|
||||||
|
<List className={classes.list}>
|
||||||
|
{fileList.map(item => (
|
||||||
|
<ListItem key={item.key} className={classes.item}>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar>
|
||||||
|
<FolderIcon />
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText
|
||||||
|
primary={item.name}
|
||||||
|
secondary={formatBytes(item.size)}
|
||||||
|
/>
|
||||||
|
<ListItemSecondaryAction
|
||||||
|
onClick={event => handleDelete(event, item.key)}
|
||||||
|
>
|
||||||
|
<IconButton edge="end" aria-label="delete">
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FileList
|
||||||
97
frontend/admin/src/components/Upload/FileUpload.tsx
Normal file
97
frontend/admin/src/components/Upload/FileUpload.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import React, { useContext, useEffect, useState } from 'react'
|
||||||
|
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'
|
||||||
|
import Divider from '@material-ui/core/Divider'
|
||||||
|
import InputBase from '@material-ui/core/InputBase'
|
||||||
|
import Paper from '@material-ui/core/Paper'
|
||||||
|
import AttachFileIcon from '@material-ui/icons/AttachFile'
|
||||||
|
import Button from '@material-ui/core/Button'
|
||||||
|
import { DEFAULT_ACCEPT_FILE_EXT } from '@constants'
|
||||||
|
import { IFile } from '@service'
|
||||||
|
import { FileContext, UploadProps } from '.'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: 28,
|
||||||
|
margin: 4,
|
||||||
|
},
|
||||||
|
fileInput: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const FileUpload = (props: UploadProps) => {
|
||||||
|
const { accept, multi } = props
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { selectedFiles, setSelectedFilesHandler } = useContext(FileContext)
|
||||||
|
const [fileCnt, setFileCnt] = useState<number>(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFileCnt(selectedFiles?.length || 0)
|
||||||
|
}, [selectedFiles])
|
||||||
|
|
||||||
|
const handleChangeFiles = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const fileList = event.target.files
|
||||||
|
let newSelectedFiles: IFile[] = []
|
||||||
|
for (const key in fileList) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(fileList, key)) {
|
||||||
|
const item = fileList[key]
|
||||||
|
newSelectedFiles.push({
|
||||||
|
key: `${Math.random().toString(36).substr(2, 11)}`,
|
||||||
|
file: item,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedFiles !== undefined) {
|
||||||
|
newSelectedFiles = newSelectedFiles.concat(selectedFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFilesHandler(newSelectedFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper component="form" className={classes.root}>
|
||||||
|
<InputBase
|
||||||
|
className={classes.input}
|
||||||
|
placeholder={t('file.placeholder')}
|
||||||
|
inputProps={{ 'aria-label': 'add attachments' }}
|
||||||
|
value={fileCnt === 0 ? '' : `${fileCnt} 개의 파일이 선택되었습니다.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider className={classes.divider} orientation="vertical" />
|
||||||
|
<input
|
||||||
|
accept={accept || DEFAULT_ACCEPT_FILE_EXT}
|
||||||
|
className={classes.fileInput}
|
||||||
|
id="contained-button-file"
|
||||||
|
onChange={handleChangeFiles}
|
||||||
|
multiple={multi}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
<label htmlFor="contained-button-file">
|
||||||
|
<Button variant="contained" color="primary" component="span">
|
||||||
|
<AttachFileIcon /> {t('file.search')}
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FileUpload
|
||||||
224
frontend/admin/src/components/Upload/index.tsx
Normal file
224
frontend/admin/src/components/Upload/index.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import CustomAlert from '@components/CustomAlert'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import {
|
||||||
|
AttachmentSavePayload,
|
||||||
|
fileService,
|
||||||
|
IAttachmentResponse,
|
||||||
|
IFile,
|
||||||
|
UploadInfoReqeust,
|
||||||
|
} from '@service'
|
||||||
|
import { format, formatBytes } from '@utils'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import FileList from './FileList'
|
||||||
|
import FileUpload from './FileUpload'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
listStyle: 'none',
|
||||||
|
position: 'unset',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export type UploadType = {
|
||||||
|
isModified: (list?: IAttachmentResponse[]) => Promise<boolean>
|
||||||
|
count: (list?: IAttachmentResponse[]) => Promise<number>
|
||||||
|
upload: (
|
||||||
|
info?: UploadInfoReqeust,
|
||||||
|
list?: IAttachmentResponse[],
|
||||||
|
) => Promise<string>
|
||||||
|
rollback: (attachmentCode: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadProps {
|
||||||
|
accept?: string
|
||||||
|
multi?: boolean
|
||||||
|
uploadLimitCount?: number
|
||||||
|
uploadLimitSize?: number
|
||||||
|
attachmentCode?: string
|
||||||
|
attachData?: IAttachmentResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileContext = createContext<{
|
||||||
|
selectedFiles: IFile[]
|
||||||
|
setSelectedFilesHandler: (files: IFile[]) => void
|
||||||
|
}>({
|
||||||
|
selectedFiles: undefined,
|
||||||
|
setSelectedFilesHandler: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Upload = forwardRef<UploadType, UploadProps>((props, ref) => {
|
||||||
|
const { attachmentCode, attachData, uploadLimitCount, uploadLimitSize } =
|
||||||
|
props
|
||||||
|
|
||||||
|
const classes = useStyles()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
// alert
|
||||||
|
const [customAlert, setCustomAlert] = useState<{
|
||||||
|
open: boolean
|
||||||
|
contentText: string
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
contentText: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const [spare, setSpare] = useState<IFile[]>(undefined)
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<IFile[]>(undefined)
|
||||||
|
const setSelectedFilesHandler = (files: IFile[]) => {
|
||||||
|
// 파일 수 체크
|
||||||
|
const uploadCount =
|
||||||
|
(attachData ? attachData.filter(file => !file.isDelete).length : 0) +
|
||||||
|
files.length
|
||||||
|
if (uploadLimitCount && uploadCount > uploadLimitCount) {
|
||||||
|
setCustomAlert({
|
||||||
|
open: true,
|
||||||
|
contentText: format(t('valid.upload_limit_count.format'), [
|
||||||
|
uploadLimitCount,
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 용량 체크
|
||||||
|
if (uploadLimitCount) {
|
||||||
|
const uploadSize = files.reduce(
|
||||||
|
(accumulator, currentValue) => accumulator + currentValue.file.size,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if (uploadSize > uploadLimitSize) {
|
||||||
|
setCustomAlert({
|
||||||
|
open: true,
|
||||||
|
contentText: format(t('valid.upload_limit_size.format'), [
|
||||||
|
`${formatBytes(uploadLimitSize, 0)}`,
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFiles(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
isModified: (list?: IAttachmentResponse[]) =>
|
||||||
|
new Promise<boolean>(resolve => {
|
||||||
|
if (selectedFiles?.length > 0) {
|
||||||
|
resolve(true)
|
||||||
|
}
|
||||||
|
if (list?.filter(m => m.isDelete).length > 0) {
|
||||||
|
resolve(true)
|
||||||
|
}
|
||||||
|
resolve(false)
|
||||||
|
}),
|
||||||
|
count: (list?: IAttachmentResponse[]) =>
|
||||||
|
new Promise<number>(resolve => {
|
||||||
|
resolve(
|
||||||
|
(selectedFiles?.length ? selectedFiles?.length : 0) +
|
||||||
|
(list ? list.filter(m => !m.isDelete).length : 0),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
upload: (info?: UploadInfoReqeust, list?: IAttachmentResponse[]) =>
|
||||||
|
new Promise<string>((resolve, reject) => {
|
||||||
|
if (selectedFiles) {
|
||||||
|
let saveList: AttachmentSavePayload[] = []
|
||||||
|
|
||||||
|
if (list && list.length > 0) {
|
||||||
|
list.map(item => {
|
||||||
|
if (item.isDelete) {
|
||||||
|
saveList.push({
|
||||||
|
uniqueId: item.id,
|
||||||
|
isDelete: item.isDelete,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setSpare(selectedFiles)
|
||||||
|
fileService
|
||||||
|
.upload({
|
||||||
|
fileList: selectedFiles,
|
||||||
|
attachmentCode: attachmentCode,
|
||||||
|
info,
|
||||||
|
list: saveList,
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
setSelectedFiles(undefined)
|
||||||
|
resolve(response.data)
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
setSelectedFiles(undefined)
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
} else if (list) {
|
||||||
|
let saveList: AttachmentSavePayload[] = []
|
||||||
|
|
||||||
|
list.map(item => {
|
||||||
|
if (item.isDelete) {
|
||||||
|
saveList.push({
|
||||||
|
uniqueId: item.id,
|
||||||
|
isDelete: item.isDelete,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (saveList.length <= 0) {
|
||||||
|
resolve('no update list')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileService
|
||||||
|
.save({
|
||||||
|
attachmentCode: attachmentCode,
|
||||||
|
info,
|
||||||
|
list: saveList,
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
resolve(response.data)
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resolve('no attachments')
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
rollback: async (attachmentCode: string) => {
|
||||||
|
try {
|
||||||
|
await fileService.deleteAll(attachmentCode)
|
||||||
|
|
||||||
|
if (spare) {
|
||||||
|
setSelectedFiles(spare)
|
||||||
|
setSpare(undefined)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`file rollback error : ${error.message}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleAlert = () => {
|
||||||
|
setCustomAlert({
|
||||||
|
...customAlert,
|
||||||
|
open: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<FileContext.Provider value={{ selectedFiles, setSelectedFilesHandler }}>
|
||||||
|
<FileUpload {...props} />
|
||||||
|
<FileList />
|
||||||
|
</FileContext.Provider>
|
||||||
|
<CustomAlert handleAlert={handleAlert} {...customAlert} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export { Upload }
|
||||||
89
frontend/admin/src/components/Wrapper/GlobalError.tsx
Normal file
89
frontend/admin/src/components/Wrapper/GlobalError.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
|
import { useSnackbar } from 'notistack'
|
||||||
|
import { errorStateAtom } from '@stores'
|
||||||
|
import { useRecoilState } from 'recoil'
|
||||||
|
import CustomAlert from '@components/CustomAlert'
|
||||||
|
import { ButtonProps } from '@material-ui/core/Button'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
paper: {
|
||||||
|
display: 'flex',
|
||||||
|
margin: theme.spacing(1),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const customAlertButtonProps: ButtonProps = {
|
||||||
|
variant: 'outlined',
|
||||||
|
color: 'secondary',
|
||||||
|
}
|
||||||
|
|
||||||
|
const GlobalError = () => {
|
||||||
|
const [errorState, setErrorState] = useRecoilState(errorStateAtom)
|
||||||
|
const { enqueueSnackbar } = useSnackbar()
|
||||||
|
const [alertState, setAlertState] = useState<{
|
||||||
|
open: boolean
|
||||||
|
errors: string[]
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
errors: [],
|
||||||
|
})
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (errorState.error) {
|
||||||
|
if (errorState.status === 400) {
|
||||||
|
const errors = errorState.errors.map(item => {
|
||||||
|
return item.defaultMessage
|
||||||
|
})
|
||||||
|
|
||||||
|
setAlertState({
|
||||||
|
open: true,
|
||||||
|
errors,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
enqueueSnackbar(errorState.message, {
|
||||||
|
variant: 'error',
|
||||||
|
onClose: resetError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [errorState])
|
||||||
|
|
||||||
|
if (!errorState.error) return null
|
||||||
|
|
||||||
|
const resetError = () => {
|
||||||
|
setAlertState({
|
||||||
|
open: false,
|
||||||
|
errors: [],
|
||||||
|
})
|
||||||
|
setErrorState({
|
||||||
|
open: false,
|
||||||
|
error: null,
|
||||||
|
message: '',
|
||||||
|
status: null,
|
||||||
|
errors: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CustomAlert
|
||||||
|
open={alertState.open}
|
||||||
|
handleAlert={resetError}
|
||||||
|
title={errorState.message}
|
||||||
|
contentText={alertState.errors}
|
||||||
|
severity="error"
|
||||||
|
classes={classes}
|
||||||
|
buttonProps={customAlertButtonProps}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalError
|
||||||
14
frontend/admin/src/components/Wrapper/SSRSafeSuspense.tsx
Normal file
14
frontend/admin/src/components/Wrapper/SSRSafeSuspense.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import useMounted from '@hooks/useMounted'
|
||||||
|
import React, { Suspense, SuspenseProps } from 'react'
|
||||||
|
|
||||||
|
const SSRSafeSuspense = (props: SuspenseProps) => {
|
||||||
|
const isMounted = useMounted()
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
return <Suspense {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{props.fallback}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SSRSafeSuspense
|
||||||
19
frontend/admin/src/components/Wrapper/index.tsx
Normal file
19
frontend/admin/src/components/Wrapper/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Loader from '@components/Loader'
|
||||||
|
import GlobalError from './GlobalError'
|
||||||
|
import SSRSafeSuspense from './SSRSafeSuspense'
|
||||||
|
|
||||||
|
export interface IWrapperProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const Wrapper = ({ children }: IWrapperProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SSRSafeSuspense fallback={<Loader />}>{children}</SSRSafeSuspense>
|
||||||
|
<GlobalError />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Wrapper
|
||||||
211
frontend/admin/src/components/comment/form.tsx
Normal file
211
frontend/admin/src/components/comment/form.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Controller, FormProvider, useForm } from 'react-hook-form'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createStyles,
|
||||||
|
makeStyles,
|
||||||
|
Theme,
|
||||||
|
useTheme,
|
||||||
|
} from '@material-ui/core/styles'
|
||||||
|
import Box from '@material-ui/core/Box'
|
||||||
|
import TextField from '@material-ui/core/TextField'
|
||||||
|
import Button from '@material-ui/core/Button'
|
||||||
|
|
||||||
|
import useUser from '@hooks/useUser'
|
||||||
|
import { IComment } from '@service'
|
||||||
|
import { format } from '@utils'
|
||||||
|
import CustomAlert from '@components/CustomAlert'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
commentCreator: {
|
||||||
|
padding: theme.spacing(1, 1, 1, 0),
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
commentContent: {
|
||||||
|
padding: theme.spacing(0),
|
||||||
|
},
|
||||||
|
commentContentInput: {
|
||||||
|
padding: '0 !important',
|
||||||
|
},
|
||||||
|
commentButtons: {
|
||||||
|
margin: theme.spacing(1, 0, 0, 0),
|
||||||
|
padding: theme.spacing(0),
|
||||||
|
},
|
||||||
|
ml1: {
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ICommentFormProps {
|
||||||
|
boardNo: number
|
||||||
|
postsNo: number
|
||||||
|
commentNo?: number
|
||||||
|
commentContent?: string
|
||||||
|
groupNo?: number
|
||||||
|
parentCommentNo?: number
|
||||||
|
depthSeq?: number
|
||||||
|
handleCommentSave: (comment: ICommentFormInput) => void
|
||||||
|
handleCommentCancel?: (comment: ICommentFormInput) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ICommentFormInput {
|
||||||
|
parentCommentNo?: number
|
||||||
|
commentContent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommentForm: React.FC<ICommentFormProps> = ({
|
||||||
|
boardNo,
|
||||||
|
postsNo,
|
||||||
|
commentNo,
|
||||||
|
commentContent,
|
||||||
|
groupNo,
|
||||||
|
parentCommentNo,
|
||||||
|
depthSeq,
|
||||||
|
handleCommentSave,
|
||||||
|
handleCommentCancel,
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const { user } = useUser()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const theme = useTheme()
|
||||||
|
|
||||||
|
// alert
|
||||||
|
const [customAlert, setCustomAlert] = useState<any>({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
handleAlert: () => setCustomAlert({ open: false }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// form hook
|
||||||
|
const methods = useForm<ICommentFormInput>({
|
||||||
|
defaultValues: {
|
||||||
|
commentContent,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { control, handleSubmit, setValue, setFocus } = methods
|
||||||
|
|
||||||
|
const saveComment = async (formData: ICommentFormInput) => {
|
||||||
|
if (!formData.commentContent) {
|
||||||
|
setCustomAlert({
|
||||||
|
open: true,
|
||||||
|
message: format(t('valid.required.format'), [
|
||||||
|
t('comment.comment_content'),
|
||||||
|
]),
|
||||||
|
handleAlert: () => {
|
||||||
|
setCustomAlert({
|
||||||
|
open: false,
|
||||||
|
})
|
||||||
|
setFocus('commentContent') // TODO 작동안함..
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment: IComment = {
|
||||||
|
boardNo,
|
||||||
|
postsNo,
|
||||||
|
commentNo,
|
||||||
|
commentContent: formData.commentContent,
|
||||||
|
groupNo,
|
||||||
|
parentCommentNo,
|
||||||
|
depthSeq: typeof depthSeq === 'undefined' ? 0 : depthSeq,
|
||||||
|
}
|
||||||
|
handleCommentSave(comment)
|
||||||
|
|
||||||
|
if (!parentCommentNo && typeof commentNo === 'undefined') {
|
||||||
|
setValue('commentContent', '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
const comment: IComment = {
|
||||||
|
boardNo,
|
||||||
|
postsNo,
|
||||||
|
commentNo,
|
||||||
|
commentContent,
|
||||||
|
groupNo,
|
||||||
|
parentCommentNo,
|
||||||
|
depthSeq,
|
||||||
|
}
|
||||||
|
if (handleCommentCancel) {
|
||||||
|
handleCommentCancel(comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setValue('commentContent', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
<form
|
||||||
|
style={{
|
||||||
|
paddingLeft: `${theme.spacing(1) + depthSeq * theme.spacing(4)}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box className={classes.commentCreator}>{user.userName}</Box>
|
||||||
|
<Controller
|
||||||
|
name="commentContent"
|
||||||
|
control={control}
|
||||||
|
rules={{ maxLength: 2000 }}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
label={t('comment.comment_content')}
|
||||||
|
className={classes.commentContent}
|
||||||
|
multiline
|
||||||
|
minRows={1}
|
||||||
|
inputProps={{
|
||||||
|
maxLength: 2000,
|
||||||
|
className: classes.commentContentInput,
|
||||||
|
}}
|
||||||
|
id="outlined-full-width"
|
||||||
|
placeholder={format(t('msg.placeholder.format'), [
|
||||||
|
t('comment.comment_content'),
|
||||||
|
])}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
className={classes.commentButtons}
|
||||||
|
display="flex"
|
||||||
|
justifyContent="flex-end"
|
||||||
|
m={1}
|
||||||
|
p={1}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={handleSubmit(saveComment)}
|
||||||
|
>
|
||||||
|
{commentNo || parentCommentNo
|
||||||
|
? t('label.button.save')
|
||||||
|
: t('label.button.reg')}
|
||||||
|
</Button>
|
||||||
|
{(commentNo || parentCommentNo) && (
|
||||||
|
<Button
|
||||||
|
className={classes.ml1}
|
||||||
|
variant="contained"
|
||||||
|
color="default"
|
||||||
|
size="small"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
{t('label.button.cancel')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
|
<CustomAlert
|
||||||
|
contentText={customAlert.message}
|
||||||
|
open={customAlert.open}
|
||||||
|
handleAlert={customAlert.handleAlert}
|
||||||
|
/>
|
||||||
|
</FormProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CommentForm }
|
||||||
2
frontend/admin/src/components/comment/index.ts
Normal file
2
frontend/admin/src/components/comment/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './form'
|
||||||
|
export * from './list'
|
||||||
488
frontend/admin/src/components/comment/list.tsx
Normal file
488
frontend/admin/src/components/comment/list.tsx
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { AxiosError } from 'axios'
|
||||||
|
import { useSetRecoilState } from 'recoil'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createStyles,
|
||||||
|
makeStyles,
|
||||||
|
Theme,
|
||||||
|
useTheme,
|
||||||
|
} from '@material-ui/core/styles'
|
||||||
|
import Box from '@material-ui/core/Box'
|
||||||
|
import Card from '@material-ui/core/Card'
|
||||||
|
import CardActions from '@material-ui/core/CardActions'
|
||||||
|
import CardContent from '@material-ui/core/CardContent'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline'
|
||||||
|
import RefreshIcon from '@material-ui/icons/Refresh'
|
||||||
|
import Link from '@material-ui/core/Link'
|
||||||
|
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
|
||||||
|
import Button from '@material-ui/core/Button'
|
||||||
|
|
||||||
|
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
|
||||||
|
import useUser from '@hooks/useUser'
|
||||||
|
import { commentService, IComment } from '@service'
|
||||||
|
import { convertStringToDateFormat } from '@libs/date'
|
||||||
|
import { ConfirmDialog, ConfirmDialogProps } from '@components/Confirm'
|
||||||
|
import { CustomButtons } from '@components/Buttons'
|
||||||
|
import { CommentForm } from './form'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
commentRoot: {
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
padding: theme.spacing(0, 2, 1, 2),
|
||||||
|
},
|
||||||
|
commentBox: {
|
||||||
|
padding: theme.spacing(2, 2, 1, 2),
|
||||||
|
},
|
||||||
|
commentTitle: {
|
||||||
|
display: 'flex',
|
||||||
|
padding: theme.spacing(2, 1),
|
||||||
|
},
|
||||||
|
commentView: {
|
||||||
|
padding: theme.spacing(0),
|
||||||
|
},
|
||||||
|
commentIcon: {
|
||||||
|
marginRight: theme.spacing(0.5),
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
},
|
||||||
|
commentContent: {
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
},
|
||||||
|
commentDate: {
|
||||||
|
marginRight: theme.spacing(3),
|
||||||
|
padding: theme.spacing(1, 0),
|
||||||
|
},
|
||||||
|
moreBox: {
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: theme.spacing(2),
|
||||||
|
},
|
||||||
|
black: {
|
||||||
|
color: 'black',
|
||||||
|
},
|
||||||
|
ml1: {
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
},
|
||||||
|
pd1: {
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
},
|
||||||
|
pdtb1: {
|
||||||
|
padding: theme.spacing(1, 0),
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ICommentProps {
|
||||||
|
boardNo: number
|
||||||
|
postsNo: number
|
||||||
|
commentUseAt: boolean
|
||||||
|
deleteAt: number
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
refreshCommentCount: (count) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ICommentSearchProps {
|
||||||
|
_page: number
|
||||||
|
_mode: 'replace' | 'append' | 'until'
|
||||||
|
}
|
||||||
|
|
||||||
|
const Comment: React.FC<ICommentProps> = ({
|
||||||
|
boardNo,
|
||||||
|
postsNo,
|
||||||
|
commentUseAt,
|
||||||
|
deleteAt,
|
||||||
|
refreshCommentCount,
|
||||||
|
}: ICommentProps) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const { user } = useUser()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const theme = useTheme()
|
||||||
|
const pagingSize = 2
|
||||||
|
|
||||||
|
// 현 페이지내 필요한 hook
|
||||||
|
const [page, setPage] = useState<number>(undefined)
|
||||||
|
const [totalPages, setTotalPages] = useState<number>(0)
|
||||||
|
|
||||||
|
// 버튼 component 상태 전이
|
||||||
|
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
|
||||||
|
|
||||||
|
// 상태관리 hook
|
||||||
|
const setErrorState = useSetRecoilState(errorStateSelector)
|
||||||
|
|
||||||
|
const [openConfirm, setOpenConfirm] = useState<boolean>(false)
|
||||||
|
const [confirm, setConfirm] = useState<ConfirmDialogProps>({
|
||||||
|
open: openConfirm,
|
||||||
|
handleConfirm: () => {
|
||||||
|
setOpenConfirm(false)
|
||||||
|
},
|
||||||
|
handleClose: () => {
|
||||||
|
setOpenConfirm(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// const [comments, setComments] = useRecoilState(commentState)
|
||||||
|
const [comments, setComments] = useState<any[]>([])
|
||||||
|
|
||||||
|
// 댓글 데이터 복사본 리턴
|
||||||
|
const cloneComments = useCallback(
|
||||||
|
() => comments.slice(0, comments.length),
|
||||||
|
[comments],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 페이지 조회
|
||||||
|
const getComments = useCallback(
|
||||||
|
({ _page, _mode }: ICommentSearchProps) => {
|
||||||
|
let searchPage = _page
|
||||||
|
let searchSize = pagingSize
|
||||||
|
if (_mode === 'until') {
|
||||||
|
searchSize = pagingSize * (_page + 1)
|
||||||
|
searchPage = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
commentService
|
||||||
|
.list(boardNo, postsNo, searchSize, searchPage)
|
||||||
|
.then(result => {
|
||||||
|
setPage(_page)
|
||||||
|
// setTotalPages(result.totalPages)
|
||||||
|
setTotalPages(Math.ceil(result.groupElements / pagingSize))
|
||||||
|
refreshCommentCount(result.totalElements)
|
||||||
|
|
||||||
|
let arr = _mode === 'append' ? cloneComments() : []
|
||||||
|
arr.push(...result.content)
|
||||||
|
|
||||||
|
setComments(arr)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[boardNo, cloneComments, postsNo, refreshCommentCount],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 전체 조회
|
||||||
|
const getAllComments = () => {
|
||||||
|
commentService.all(boardNo, postsNo).then(result => {
|
||||||
|
setPage(result.number)
|
||||||
|
setTotalPages(result.totalPages)
|
||||||
|
refreshCommentCount(result.totalElements)
|
||||||
|
|
||||||
|
let arr = []
|
||||||
|
arr.push(...result.content)
|
||||||
|
|
||||||
|
setComments(arr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page === undefined) {
|
||||||
|
getComments({ _page: 0, _mode: 'replace' })
|
||||||
|
}
|
||||||
|
}, [getComments, page])
|
||||||
|
|
||||||
|
// 댓글 갱신
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
// getComments({ _page: 0, _mode: 'replace' }) // 첫페이지 재조회
|
||||||
|
getComments({ _page: page, _mode: 'until' }) // 현재 페이지까지 재조회
|
||||||
|
}, [getComments, page])
|
||||||
|
|
||||||
|
// 댓글 상태 초기화
|
||||||
|
const initComments = useCallback(() => {
|
||||||
|
let arr: IComment[] = cloneComments()
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const index = arr.findIndex(a => a.mode === 'reply' || a.mode === 'edit')
|
||||||
|
if (index === -1) break
|
||||||
|
|
||||||
|
if (arr[index].mode === 'reply') {
|
||||||
|
arr.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
arr[index].mode = 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return arr
|
||||||
|
}, [cloneComments])
|
||||||
|
|
||||||
|
// 성공 callback
|
||||||
|
const successCallback = useCallback(() => {
|
||||||
|
setSuccessSnackBar('success')
|
||||||
|
|
||||||
|
// handleRefresh()
|
||||||
|
|
||||||
|
let arr: IComment[] = initComments()
|
||||||
|
setComments(arr)
|
||||||
|
}, [initComments, setSuccessSnackBar])
|
||||||
|
|
||||||
|
// 에러 callback
|
||||||
|
const errorCallback = useCallback(
|
||||||
|
(error: AxiosError) => {
|
||||||
|
setErrorState({
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[setErrorState],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 댓글 더보기
|
||||||
|
const handleCommentMore = () => {
|
||||||
|
getComments({ _page: page + 1, _mode: 'append' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 댓글 답글쓰기
|
||||||
|
const handleCommentReply = async (parentCommentNo: number) => {
|
||||||
|
let arr: IComment[] = initComments()
|
||||||
|
|
||||||
|
const parentIndex = arr.findIndex(a => a.commentNo === parentCommentNo)
|
||||||
|
|
||||||
|
const reply: IComment = {
|
||||||
|
boardNo,
|
||||||
|
postsNo,
|
||||||
|
groupNo: arr[parentIndex].groupNo,
|
||||||
|
parentCommentNo,
|
||||||
|
depthSeq: arr[parentIndex].depthSeq + 1,
|
||||||
|
createdBy: user.userId,
|
||||||
|
createdName: user.userName,
|
||||||
|
commentContent: '',
|
||||||
|
mode: 'reply',
|
||||||
|
}
|
||||||
|
|
||||||
|
arr.splice(parentIndex + 1, 0, reply)
|
||||||
|
|
||||||
|
setComments(arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 댓글 수정
|
||||||
|
const handleCommentEdit = async (commentNo: number) => {
|
||||||
|
let arr: IComment[] = initComments()
|
||||||
|
|
||||||
|
const index = arr.findIndex(a => a.commentNo === commentNo)
|
||||||
|
|
||||||
|
arr[index].mode = 'edit'
|
||||||
|
setComments(arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 댓글 삭제
|
||||||
|
const handleCommentDelete = async (commentNo: number) => {
|
||||||
|
setConfirm({
|
||||||
|
open: openConfirm,
|
||||||
|
contentText: t('msg.confirm.delete'),
|
||||||
|
handleConfirm: () => {
|
||||||
|
setOpenConfirm(false)
|
||||||
|
|
||||||
|
commentService.delete({
|
||||||
|
boardNo,
|
||||||
|
postsNo,
|
||||||
|
commentNo,
|
||||||
|
callback: successCallback,
|
||||||
|
errorCallback,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handleClose: () => {
|
||||||
|
setOpenConfirm(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setOpenConfirm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSubmit 댓글 저장
|
||||||
|
const handleCommentSave = async (comment: IComment) => {
|
||||||
|
if (comment.commentNo > 0) {
|
||||||
|
await commentService.update({
|
||||||
|
callback: () => {
|
||||||
|
successCallback()
|
||||||
|
|
||||||
|
getComments({ _page: page, _mode: 'until' }) // 현재 페이지까지 재조회
|
||||||
|
},
|
||||||
|
errorCallback,
|
||||||
|
data: comment,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await commentService.save({
|
||||||
|
callback: () => {
|
||||||
|
successCallback()
|
||||||
|
|
||||||
|
if (comment.parentCommentNo) {
|
||||||
|
getComments({ _page: page, _mode: 'until' }) // 현재 페이지까지 재조회
|
||||||
|
} else {
|
||||||
|
getAllComments() // 마지막 페이지까지 조회
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errorCallback,
|
||||||
|
data: comment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 취소
|
||||||
|
const handleCommentCancel = async () => {
|
||||||
|
let arr: IComment[] = initComments()
|
||||||
|
|
||||||
|
setComments(arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box boxShadow={1} className={classes.commentRoot}>
|
||||||
|
<Box className={classes.commentTitle}>
|
||||||
|
<Typography variant="h4" component="h3" className={classes.pdtb1}>
|
||||||
|
{t('comment')}
|
||||||
|
</Typography>
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
className={classNames({
|
||||||
|
[classes.black]: true,
|
||||||
|
[classes.ml1]: true,
|
||||||
|
[classes.pdtb1]: true,
|
||||||
|
})}
|
||||||
|
onClick={(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
handleRefresh()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshIcon fontSize="small" className={classes.commentIcon} />
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
{comments &&
|
||||||
|
comments.map(comment => {
|
||||||
|
if (comment.mode !== 'edit' && comment.mode !== 'reply') {
|
||||||
|
let buttons = []
|
||||||
|
|
||||||
|
if (commentUseAt && deleteAt === 0) {
|
||||||
|
buttons.push({
|
||||||
|
label: t('label.button.reply'),
|
||||||
|
size: 'small',
|
||||||
|
handleButton: () => {
|
||||||
|
handleCommentReply(comment.commentNo)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (user?.userId === comment.createdBy) {
|
||||||
|
buttons.push({
|
||||||
|
label: t('label.button.edit'),
|
||||||
|
size: 'small',
|
||||||
|
handleButton: () => {
|
||||||
|
handleCommentEdit(comment.commentNo)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
buttons.push({
|
||||||
|
label: t('label.button.delete'),
|
||||||
|
size: 'small',
|
||||||
|
handleButton: () => {
|
||||||
|
handleCommentDelete(comment.commentNo)
|
||||||
|
},
|
||||||
|
completeMessage: t('msg.success.delete'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={`comment${comment.commentNo}`}
|
||||||
|
className={classNames({
|
||||||
|
[classes.commentBox]: true,
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
paddingLeft: `${
|
||||||
|
theme.spacing(2) + comment.depthSeq * theme.spacing(4)
|
||||||
|
}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent className={classes.commentView}>
|
||||||
|
<Typography gutterBottom variant="h6" component="h4">
|
||||||
|
{comment.createdName}
|
||||||
|
</Typography>
|
||||||
|
{comment.deleteAt !== 0 && (
|
||||||
|
<>
|
||||||
|
<ErrorOutlineIcon
|
||||||
|
fontSize="small"
|
||||||
|
className={classes.commentIcon}
|
||||||
|
/>
|
||||||
|
{comment.deleteAt === 1 && t('common.delete.creator')}
|
||||||
|
{comment.deleteAt === 2 && t('common.delete.manager')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Typography variant="body2" color="textPrimary" component="p">
|
||||||
|
<Box
|
||||||
|
className={classNames({
|
||||||
|
[classes.commentContent]: true,
|
||||||
|
[classes.cancel]: comment.deleteAt !== 0,
|
||||||
|
})}
|
||||||
|
component="span"
|
||||||
|
>
|
||||||
|
{comment.commentContent}
|
||||||
|
</Box>
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions className={classes.commentView}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="textSecondary"
|
||||||
|
component="p"
|
||||||
|
className={classes.commentDate}
|
||||||
|
>
|
||||||
|
{comment.createdDate
|
||||||
|
? convertStringToDateFormat(
|
||||||
|
comment.createdDate,
|
||||||
|
'yyyy-MM-dd HH:mm:ss',
|
||||||
|
)
|
||||||
|
: ''}
|
||||||
|
</Typography>
|
||||||
|
{comment.deleteAt === 0 && (
|
||||||
|
<CustomButtons buttons={buttons} className="mg0" />
|
||||||
|
)}
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={`comment${comment.commentNo}`}
|
||||||
|
boxShadow={1}
|
||||||
|
className={classes.pd1}
|
||||||
|
>
|
||||||
|
<CommentForm
|
||||||
|
boardNo={boardNo}
|
||||||
|
postsNo={postsNo}
|
||||||
|
commentNo={comment.commentNo}
|
||||||
|
commentContent={comment.commentContent}
|
||||||
|
groupNo={comment.groupNo}
|
||||||
|
parentCommentNo={comment.parentCommentNo}
|
||||||
|
depthSeq={comment.depthSeq}
|
||||||
|
handleCommentSave={handleCommentSave}
|
||||||
|
handleCommentCancel={handleCommentCancel}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Box className={classes.moreBox} hidden={page + 1 >= totalPages}>
|
||||||
|
<Button
|
||||||
|
startIcon={<ExpandMoreIcon />}
|
||||||
|
endIcon={<ExpandMoreIcon />}
|
||||||
|
onClick={handleCommentMore}
|
||||||
|
>
|
||||||
|
{t('common.more')}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{commentUseAt && deleteAt === 0 && (
|
||||||
|
<Box className={classes.pdtb1}>
|
||||||
|
<CommentForm
|
||||||
|
handleCommentSave={handleCommentSave}
|
||||||
|
boardNo={boardNo}
|
||||||
|
postsNo={postsNo}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={openConfirm}
|
||||||
|
contentText={confirm.contentText}
|
||||||
|
handleClose={confirm.handleClose}
|
||||||
|
handleConfirm={confirm.handleConfirm}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Comment }
|
||||||
14
frontend/admin/src/constants/env.ts
Normal file
14
frontend/admin/src/constants/env.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export const DEV = process.env.NODE_ENV !== 'production'
|
||||||
|
export const PORT = process.env.PORT || '3000'
|
||||||
|
export const PROXY_HOST = process.env.PROXY_HOST || `http://localhost:${PORT}`
|
||||||
|
|
||||||
|
export const TZ = process.env.TZ || 'Asia/Seoul'
|
||||||
|
|
||||||
|
export const SERVER_API_URL = process.env.SERVER_API_URL
|
||||||
|
|
||||||
|
export const CLAIM_NAME = process.env.CLAIM_NAME || 'Authorization'
|
||||||
|
export const AUTH_USER_ID = process.env.AUTH_USER_ID || 'token-id'
|
||||||
|
export const REFRESH_TOKEN = process.env.REFRESH_TOKEN || 'refresh-token'
|
||||||
|
export const ACCESS_TOKEN = process.env.ACCESS_TOKEN || 'access-token'
|
||||||
|
|
||||||
|
export const SITE_ID = process.env.SITE_ID
|
||||||
33
frontend/admin/src/constants/index.ts
Normal file
33
frontend/admin/src/constants/index.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { PROXY_HOST } from './env'
|
||||||
|
|
||||||
|
export const DRAWER_WIDTH = 220
|
||||||
|
|
||||||
|
export const GRID_ROW_HEIGHT = 40
|
||||||
|
|
||||||
|
export const GRID_PAGE_SIZE = 10
|
||||||
|
|
||||||
|
export const GRID_ROWS_PER_PAGE_OPTION = [10, 20, 50, 100]
|
||||||
|
|
||||||
|
export const DEFAULT_ERROR_MESSAGE = 'Sorry.. Something Wrong...😱'
|
||||||
|
|
||||||
|
export const DEFAULT_APP_NAME = 'MSA Admin Template'
|
||||||
|
|
||||||
|
export const EDITOR_LOAD_IMAGE_URL = '/portal-service/api/v1/images/editor/'
|
||||||
|
|
||||||
|
// .htm, .html, .txt, .png/.jpg/etc, .pdf, .xlsx. .xls
|
||||||
|
export const DEFAULT_ACCEPT_FILE_EXT =
|
||||||
|
'text/html, text/plain, image/*, .pdf, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel'
|
||||||
|
|
||||||
|
export const BASE_URL = `${PROXY_HOST}/server`
|
||||||
|
|
||||||
|
export const ADMIN_LOGO_PATH = '/images/adminLogo.png'
|
||||||
|
|
||||||
|
export const ADMIN_LOGO_TEXT = 'MSA Admin'
|
||||||
|
|
||||||
|
export const CUSTOM_HEADER_SITE_ID_KEY = 'X-Site-Id'
|
||||||
|
|
||||||
|
export const ACCESS_LOG_TIMEOUT = 30 * 60 * 1000
|
||||||
|
|
||||||
|
export const ACCESS_LOG_ID = 'accessLogId'
|
||||||
|
|
||||||
|
export const PUBLIC_PAGES = ['/404', '/', '/reload', '/_error']
|
||||||
25
frontend/admin/src/hooks/useLocalStorage.ts
Normal file
25
frontend/admin/src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export const useLocalStorage = (key: string, initialValue: unknown = '') => {
|
||||||
|
const [storeValue, setStoreValue] = useState(() => {
|
||||||
|
try {
|
||||||
|
const item = window.localStorage.getItem(key)
|
||||||
|
return item ? JSON.parse(item) : initialValue
|
||||||
|
} catch (error) {
|
||||||
|
return initialValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const setValue = (value: unknown) => {
|
||||||
|
try {
|
||||||
|
const valueToStore = value instanceof Function ? value(storeValue) : value
|
||||||
|
|
||||||
|
setStoreValue(valueToStore)
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(valueToStore))
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`useLocalStorage setValue error : ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [storeValue, setValue]
|
||||||
|
}
|
||||||
11
frontend/admin/src/hooks/useMounted.ts
Normal file
11
frontend/admin/src/hooks/useMounted.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export default function useMounted() {
|
||||||
|
const [mounted, setMounted] = useState<boolean>(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return mounted
|
||||||
|
}
|
||||||
17
frontend/admin/src/hooks/usePage.ts
Normal file
17
frontend/admin/src/hooks/usePage.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { pageAtom, pageSelector } from '@stores'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRecoilValue, useSetRecoilState } from 'recoil'
|
||||||
|
|
||||||
|
export default function usePage(conditionKey: string, initPage: number = 0) {
|
||||||
|
const pageState = useRecoilValue(pageAtom(conditionKey))
|
||||||
|
const setValue = useSetRecoilState(pageSelector(conditionKey))
|
||||||
|
|
||||||
|
const [page, setPage] = useState<number>(pageState || initPage)
|
||||||
|
|
||||||
|
const setPageValue = (num: number) => {
|
||||||
|
setValue(num)
|
||||||
|
setPage(num)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { page, setPageValue }
|
||||||
|
}
|
||||||
12
frontend/admin/src/hooks/useSearchType.ts
Normal file
12
frontend/admin/src/hooks/useSearchType.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { IKeywordType } from '@components/Search'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export default function useSearchTypes(init: IKeywordType[]) {
|
||||||
|
const [searchTypes, setSearchTypes] = useState<IKeywordType[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchTypes(init)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return searchTypes
|
||||||
|
}
|
||||||
36
frontend/admin/src/hooks/useUser.ts
Normal file
36
frontend/admin/src/hooks/useUser.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import useSWR from 'swr'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { AUTH_USER_ID } from '@constants/env'
|
||||||
|
import { loginSerivce } from '@service'
|
||||||
|
|
||||||
|
export default function useUser() {
|
||||||
|
const { data, error, mutate } = useSWR(
|
||||||
|
`/user-service/api/v1/users`,
|
||||||
|
async (url: string) => {
|
||||||
|
let userId = axios.defaults.headers.common[AUTH_USER_ID]
|
||||||
|
if (!userId) {
|
||||||
|
await loginSerivce.silentRefresh()
|
||||||
|
}
|
||||||
|
userId = axios.defaults.headers.common[AUTH_USER_ID]
|
||||||
|
if (userId) {
|
||||||
|
return axios.get(`${url}/${userId}`).then(res => res.data)
|
||||||
|
} else {
|
||||||
|
throw new Error('No User')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const loading = !data && !error
|
||||||
|
const isLogin = !Boolean(error) && Boolean(data)
|
||||||
|
const loggedOut =
|
||||||
|
error && (error.response?.status === 401 || error.response?.status === 403)
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: data,
|
||||||
|
loading: loading,
|
||||||
|
isLogin,
|
||||||
|
error,
|
||||||
|
mutate,
|
||||||
|
loggedOut,
|
||||||
|
}
|
||||||
|
}
|
||||||
66
frontend/admin/src/libs/Storage/emailStorage.ts
Normal file
66
frontend/admin/src/libs/Storage/emailStorage.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { loginFormType } from '@components/Auth/LoginForm'
|
||||||
|
import { LocalStorageWorker } from './index'
|
||||||
|
|
||||||
|
// custom class for store emails in local storage
|
||||||
|
export class EmailStorage {
|
||||||
|
private storageWorker: LocalStorageWorker
|
||||||
|
|
||||||
|
// main key
|
||||||
|
private storageKey: string
|
||||||
|
|
||||||
|
// login info data
|
||||||
|
private loginInfo: loginFormType
|
||||||
|
|
||||||
|
constructor(storageKey: string) {
|
||||||
|
this.storageWorker = new LocalStorageWorker()
|
||||||
|
|
||||||
|
this.storageKey = storageKey
|
||||||
|
|
||||||
|
this.loginInfo = { email: null, password: null, isRemember: false }
|
||||||
|
|
||||||
|
this.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// activate custom storage for login info
|
||||||
|
activate() {
|
||||||
|
this.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
var storageData = this.storageWorker.get(this.storageKey)
|
||||||
|
|
||||||
|
if (storageData != null && storageData.length > 0) {
|
||||||
|
var info = JSON.parse(storageData)
|
||||||
|
if (info) {
|
||||||
|
this.loginInfo = info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get() {
|
||||||
|
return this.loginInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// add new email (without duplicate)
|
||||||
|
set(info: loginFormType) {
|
||||||
|
if (info.isRemember) {
|
||||||
|
this.loginInfo = info
|
||||||
|
// save to storage
|
||||||
|
this.save()
|
||||||
|
} else {
|
||||||
|
this.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear all data about login info
|
||||||
|
clear() {
|
||||||
|
// remove with key
|
||||||
|
this.storageWorker.remove(this.storageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// save to storage (save as JSON string)
|
||||||
|
save() {
|
||||||
|
var jsonInfo = JSON.stringify(this.loginInfo)
|
||||||
|
this.storageWorker.add(this.storageKey, jsonInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user