frontend add

This commit is contained in:
shinmj
2021-10-21 09:03:17 +09:00
parent 8caa4bbc5a
commit cb9d50511e
443 changed files with 88282 additions and 0 deletions

2
frontend/README.md Normal file
View File

@@ -0,0 +1,2 @@
# egovframe-msa-template-frontend
msa template frontend - 클라우드 네이티브 기반의 행정,공공기관 서비스 확산 지원 사업

22
frontend/admin/.babelrc Normal file
View 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"
}
}
]
]
}

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

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

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

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom'

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

View File

@@ -0,0 +1,6 @@
module.exports = {
i18n: {
locales: ['ko', 'en'],
defaultLocale: 'ko',
},
}

View 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

File diff suppressed because it is too large Load Diff

101
frontend/admin/package.json Normal file
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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

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

View 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

View 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

View 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

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

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

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

View File

@@ -0,0 +1,3 @@
export * from './DetailButtons'
export * from './GridButtons'
export * from './CustomButtons'

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

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

View File

@@ -0,0 +1,2 @@
export * from './ConfirmPopover'
export * from './ConfirmDialog'

View File

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

View File

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

View File

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

View File

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

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

View 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

View 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

View 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

View File

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

View 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

View 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

View 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

View File

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

View File

@@ -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">
&bull;
</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

View File

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

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

View 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

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

View 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

View File

@@ -0,0 +1,2 @@
export * from './MenuEditForm'
export * from './ValidationAlert'

View 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

View 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

View 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

View 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

View 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

View 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

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

View 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

View 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

View 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

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

View 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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,4 @@
export * from './ReserveItemInfo'
export * from './ReserveInfo'
export * from './ReserveClientInfo'
export * from './ReserveInofView'

View File

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

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

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

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

View File

@@ -0,0 +1,3 @@
export * from './ReserveItemBasic'
export * from './ReserveItemAdditional'
export * from './ReserveItemManager'

View 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

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

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

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

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

View File

@@ -0,0 +1 @@
export * from './Horizontal'

View 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

View 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

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

View 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

View 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

View 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

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

View File

@@ -0,0 +1,2 @@
export * from './form'
export * from './list'

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

View 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

View 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']

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

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

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

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

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

View File

@@ -0,0 +1,66 @@
import { loginFormType } from '@components/Auth/LoginForm'
import { LocalStorageWorker } from './index'
// custom class for store emails in local storage
export class EmailStorage {
private storageWorker: LocalStorageWorker
// main key
private storageKey: string
// login info data
private loginInfo: loginFormType
constructor(storageKey: string) {
this.storageWorker = new LocalStorageWorker()
this.storageKey = storageKey
this.loginInfo = { email: null, password: null, isRemember: false }
this.activate()
}
// activate custom storage for login info
activate() {
this.load()
}
load() {
var storageData = this.storageWorker.get(this.storageKey)
if (storageData != null && storageData.length > 0) {
var info = JSON.parse(storageData)
if (info) {
this.loginInfo = info
}
}
}
get() {
return this.loginInfo
}
// add new email (without duplicate)
set(info: loginFormType) {
if (info.isRemember) {
this.loginInfo = info
// save to storage
this.save()
} else {
this.clear()
}
}
// clear all data about login info
clear() {
// remove with key
this.storageWorker.remove(this.storageKey)
}
// save to storage (save as JSON string)
save() {
var jsonInfo = JSON.stringify(this.loginInfo)
this.storageWorker.add(this.storageKey, jsonInfo)
}
}

Some files were not shown because too many files have changed in this diff Show More