✨ frontend add
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
92
frontend/admin/src/libs/Storage/index.ts
Normal file
92
frontend/admin/src/libs/Storage/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// module with classes and logic for working with local storage in browsers via JavaScript
|
||||
// see also: http://professorweb.ru/my/html/html5/level5/5_1.php
|
||||
|
||||
export interface IStorageItem {
|
||||
key: string
|
||||
value: any
|
||||
}
|
||||
|
||||
export class StorageItem {
|
||||
key: string
|
||||
value: any
|
||||
|
||||
constructor(data: IStorageItem) {
|
||||
this.key = data.key
|
||||
this.value = data.value
|
||||
}
|
||||
}
|
||||
|
||||
// class for working with local storage in browser (common that can use other classes for store some data)
|
||||
export class LocalStorageWorker {
|
||||
localStorageSupported: boolean
|
||||
|
||||
constructor() {
|
||||
this.localStorageSupported =
|
||||
typeof window['localStorage'] != 'undefined' &&
|
||||
window['localStorage'] != null
|
||||
}
|
||||
|
||||
// add value to storage
|
||||
add(key: string, item: string) {
|
||||
if (this.localStorageSupported) {
|
||||
localStorage.setItem(key, item)
|
||||
}
|
||||
}
|
||||
|
||||
// get all values from storage (all items)
|
||||
getAllItems(): Array<StorageItem> {
|
||||
var list = new Array<StorageItem>()
|
||||
|
||||
for (var i = 0; i < localStorage.length; i++) {
|
||||
var key = localStorage.key(i)
|
||||
var value = localStorage.getItem(key)
|
||||
|
||||
list.push(
|
||||
new StorageItem({
|
||||
key: key,
|
||||
value: value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
// get only all values from localStorage
|
||||
getAllValues(): Array<any> {
|
||||
var list = new Array<any>()
|
||||
|
||||
for (var i = 0; i < localStorage.length; i++) {
|
||||
var key = localStorage.key(i)
|
||||
var value = localStorage.getItem(key)
|
||||
|
||||
list.push(value)
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
// get one item by key from storage
|
||||
get(key: string): string {
|
||||
if (this.localStorageSupported) {
|
||||
var item = localStorage.getItem(key)
|
||||
return item
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// remove value from storage
|
||||
remove(key: string) {
|
||||
if (this.localStorageSupported) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
|
||||
// clear storage (remove all items from it)
|
||||
clear() {
|
||||
if (this.localStorageSupported) {
|
||||
localStorage.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user