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

View File

@@ -0,0 +1,48 @@
import Button from '@material-ui/core/Button'
import Card from '@material-ui/core/Card'
import CardContent from '@material-ui/core/CardContent'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Typography from '@material-ui/core/Typography'
import { useRouter } from 'next/router'
import React from 'react'
const useStyles = makeStyles((_: Theme) =>
createStyles({
content: {
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBottom: '2rem',
},
pos: {
marginBottom: '3rem',
},
}),
)
const Error404 = props => {
const classes = useStyles()
const router = useRouter()
return (
<Card>
<CardContent className={classes.content}>
<Typography variant="h5" component="h2">
404 Not Found
</Typography>
<Typography className={classes.pos} color="textSecondary">
The page you were looking for doesn&apos;t exist
</Typography>
<Button
variant="contained"
color="primary"
onClick={() => router.push('/')}
>
Back to Home
</Button>
</CardContent>
</Card>
)
}
export default Error404

View File

@@ -0,0 +1,120 @@
import React, { useEffect, useRef, useState } from 'react'
import { NextPageContext } from 'next'
import Head from 'next/head'
import { AppContext, AppProps } from 'next/app'
import { ThemeProvider } from '@material-ui/core/styles'
import CssBaseline from '@material-ui/core/CssBaseline'
import { Theme } from '@material-ui/core/styles'
import { RecoilRoot } from 'recoil'
import { SnackbarProvider } from 'notistack'
import theme from '@styles/theme'
import darkTheme from '@styles/darkTheme'
import App from '@components/App/App'
import axios from 'axios'
import '@libs/i18n'
import { appWithTranslation, useTranslation } from 'next-i18next'
import { useLocalStorage } from '@hooks/useLocalStorage'
import { SITE_ID } from '@constants/env'
import { BASE_URL, CUSTOM_HEADER_SITE_ID_KEY } from '@constants'
import { CookiesProvider } from 'react-cookie'
import 'react-datepicker/dist/react-datepicker.css'
export type PageProps = {
pathname?: string
query?: NextPageContext['query']
req?: NextPageContext['req']
}
// axios 기본 설정
axios.defaults.headers.common[CUSTOM_HEADER_SITE_ID_KEY] = SITE_ID
axios.defaults.baseURL = BASE_URL
axios.defaults.withCredentials = true
const MyApp = (props: AppProps) => {
const { Component, pageProps } = props
/**
* locales
*/
const { i18n } = useTranslation()
const [storedValue, setValue] = useLocalStorage('locale', i18n.language)
useEffect(() => {
if (storedValue !== i18n.language) {
i18n.changeLanguage(storedValue)
}
}, [i18n, storedValue])
/**
* @TODO
* 테마 선택시 사용 (언제??)
*/
const [selectTheme, setSelectTheme] = useState<Theme>(theme)
useEffect(() => {
// Remove the server-side injected CSS.
const jssStyles = document.querySelector('#jss-server-side')
if (jssStyles) {
jssStyles.parentElement!.removeChild(jssStyles)
}
}, [])
return (
<RecoilRoot>
<ThemeProvider theme={selectTheme}>
<Head>
<link rel="icon" type="image/x-icon" href="/favicon.ico"></link>
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
</Head>
{/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
<CssBaseline />
<SnackbarProvider
maxSnack={3}
iconVariant={{
success: '✅ ',
error: '✖ ',
warning: '⚠ ',
info: ' ',
}}
autoHideDuration={2000}
preventDuplicate={true}
>
<CookiesProvider>
<App component={Component} {...pageProps} />
</CookiesProvider>
</SnackbarProvider>
</ThemeProvider>
</RecoilRoot>
)
}
MyApp.getInitialProps = async (context: AppContext) => {
const { Component, ctx, router } = context
let pageProps: PageProps = {}
const locale = router.locale
axios.defaults.headers.common[CUSTOM_HEADER_SITE_ID_KEY] = SITE_ID
if (Component.getInitialProps) {
const componentInitialProps = await Component.getInitialProps(ctx)
if (componentInitialProps) {
pageProps = componentInitialProps
}
}
global.__localeId__ = locale
pageProps.pathname = ctx.pathname
return { pageProps }
}
export default appWithTranslation(MyApp)

View File

@@ -0,0 +1,86 @@
import React from 'react'
import Document, {
Html,
Head,
Main,
NextScript,
DocumentContext,
} from 'next/document'
import { ServerStyleSheets } from '@material-ui/core/styles'
import theme from '@styles/theme'
export default class MyDocument extends Document {
loadWindowProperty = locale => (
<script
dangerouslySetInnerHTML={{ __html: `window.__localeId__= "${locale}"` }}
></script>
)
render() {
const { loadWindowProperty } = this
const { locale } = this.props
return (
<Html lang={locale}>
<Head>
{/* PWA primary color */}
<meta name="theme-color" content={theme.palette.primary.main} />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
/>
</Head>
<body>
{this.loadWindowProperty(locale)}
<Main />
<NextScript />
</body>
</Html>
)
}
}
// `getInitialProps` belongs to `_document` (instead of `_app`),
// it's compatible with server-side generation (SSG).
MyDocument.getInitialProps = async (ctx: DocumentContext) => {
// Resolution order
//
// On the server:
// 1. app.getInitialProps
// 2. page.getInitialProps
// 3. document.getInitialProps
// 4. app.render
// 5. page.render
// 6. document.render
//
// On the server with error:
// 1. document.getInitialProps
// 2. app.render
// 3. page.render
// 4. document.render
//
// On the client
// 1. app.getInitialProps
// 2. page.getInitialProps
// 3. app.render
// 4. page.render
// Render app and page and get the context of the page with collected side effects.
const sheets = new ServerStyleSheets()
const originalRenderPage = ctx.renderPage
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: App => props => sheets.collect(<App {...props} />),
})
const initialProps = await Document.getInitialProps(ctx)
return {
...initialProps,
// Styles fragment is rendered after the app and page rendering finish.
styles: [
...React.Children.toArray(initialProps.styles),
sheets.getStyleElement(),
],
}
}

View File

@@ -0,0 +1,19 @@
import React from 'react'
import { NextPageContext } from 'next'
const Error = ({ statusCode }) => {
return (
<p>
{statusCode
? `An error ${statusCode} occurred on server`
: 'An error occurred on client'}
</p>
)
}
Error.getInitialProps = ({ res, err }: NextPageContext) => {
const statusCode = res ? res.statusCode : err ? err.statusCode : 404
return { statusCode }
}
export default Error

View File

@@ -0,0 +1,89 @@
import Cookies from 'cookies'
import multer from 'multer'
import { NextApiRequest, NextApiResponse } from 'next'
import axios from 'axios'
import { ACCESS_TOKEN, CLAIM_NAME, SERVER_API_URL } from '@constants/env'
import { EDITOR_LOAD_IMAGE_URL } from '@constants'
const upload = multer({
storage: multer.memoryStorage(),
})
const initMiddleware = (middleware: any) => {
return (req: NextApiRequest, res: NextApiResponse) =>
new Promise((resolve, reject) => {
middleware(req, res, result => {
if (result instanceof Error) {
return reject(result)
}
return resolve(result)
})
})
}
// for parsing multipart/form-data
// editor 요청인 경우 무조건 single임
const multerAny = initMiddleware(upload.single('upload'))
type NextApiRequestWithFormData = NextApiRequest & {
file: Express.Multer.File
}
export const config = {
api: {
bodyParser: false,
},
}
export default async (
req: NextApiRequestWithFormData,
res: NextApiResponse,
) => {
await multerAny(req, res)
//첨부파일 base64 endoding -> 서버에서 decoding 필요
if (req.file.size > 300000) {
res.status(501).json({ message: 'File is too big!! 😵‍💫' })
return
}
const base64Encoding = req.file.buffer.toString('base64')
const body = {
fieldName: req.file.fieldname,
originalName: req.file.originalname,
fileType: req.file.mimetype,
size: req.file.size,
fileBase64: base64Encoding,
}
//headers
let editorHeaders = {
'Content-Type': 'application/json',
}
const cookies = new Cookies(req, res)
const authToken = cookies.get(ACCESS_TOKEN)
// header에 authentication 추가
if (authToken) {
editorHeaders[CLAIM_NAME] = authToken
}
const result = await axios.post(
`${SERVER_API_URL}/portal-service/api/v1/upload/editor`,
body,
{
headers: editorHeaders,
},
)
let data = {}
if (result) {
data = {
...result.data,
url: `${SERVER_API_URL}${EDITOR_LOAD_IMAGE_URL}${result.data.url}`,
}
}
res.status(200).json(data)
}

View File

@@ -0,0 +1,90 @@
import { CUSTOM_HEADER_SITE_ID_KEY, DEFAULT_ERROR_MESSAGE } from '@constants'
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
REFRESH_TOKEN,
SERVER_API_URL,
SITE_ID,
} from '@constants/env'
import axios from 'axios'
import Cookies from 'cookies'
import { NextApiRequest, NextApiResponse } from 'next'
import url from 'url'
export default async (req: NextApiRequest, res: NextApiResponse) => {
const pathname = url.pathToFileURL(req.url).pathname
let isLogin = pathname === '/api/proxy/user-service/login'
req.url = req.url.replace(/^\/api\/proxy/, '')
if (pathname.indexOf('undefined') > -1) {
res.status(500).json({ message: DEFAULT_ERROR_MESSAGE })
res.end()
return
}
let headers = {
'Content-Type': 'application/json',
}
headers[CUSTOM_HEADER_SITE_ID_KEY] = SITE_ID
//silent refresh
if (pathname.indexOf('/refresh') > -1) {
isLogin = true
const cookies = new Cookies(req, res)
headers[CLAIM_NAME] = cookies.get(REFRESH_TOKEN)
req.url = '/user-service/api/v1/users/token/refresh'
if (!headers[CLAIM_NAME] || headers[CLAIM_NAME] === '') {
console.warn(`can't refresh`)
res.status(401).json({ message: 'Invalid Credentials 🥺' })
res.end()
return
}
}
// server API 에 쿠키를 전달하지 않음.
req.headers.cookie = ''
req.headers[CUSTOM_HEADER_SITE_ID_KEY] = SITE_ID
console.info(`req.url : ${req.url}`)
try {
const result = await fetch(`${SERVER_API_URL}${req.url}`, {
method: req.method,
headers,
body: req.body,
})
if (result) {
const refreshToken = result.headers.get(REFRESH_TOKEN)
const accessToken = result.headers.get(ACCESS_TOKEN)
const userId = result.headers.get(AUTH_USER_ID)
const cookies = new Cookies(req, res)
cookies.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: 'lax', //CSRF protection
})
if (accessToken) {
let payload = {}
payload[ACCESS_TOKEN] = accessToken
payload[AUTH_USER_ID] = userId
axios.defaults.headers.common[CLAIM_NAME] = accessToken
axios.defaults.headers.common[AUTH_USER_ID] = userId
res.status(200).json(payload)
} else {
res.status(401).json({ message: 'Invalid Credentials 🥺' })
}
} else {
res.status(401).json({ message: 'Invalid Credentials 🥺' })
}
res.end()
} catch (error) {
res.status(500).json({ message: DEFAULT_ERROR_MESSAGE, error })
res.end()
}
}

View File

@@ -0,0 +1,61 @@
import { ACCESS_TOKEN, CLAIM_NAME, SERVER_API_URL } from '@constants/env'
import axios from 'axios'
import Cookies from 'cookies'
import fs from 'fs'
import { NextApiRequest, NextApiResponse } from 'next'
export const config = {
api: {
bodyParser: false,
},
}
const MESSAGE_URL = `${SERVER_API_URL}/portal-service/api/v1/messages/`
const locales = ['ko', 'en']
const FILE_PATH = `public/locales/`
/**
* messages reload
*/
export default async (req: NextApiRequest, res: NextApiResponse) => {
const cookies = new Cookies(req, res)
const authToken = cookies.get(ACCESS_TOKEN)
// server 에 cookie 전달하지 않음
req.headers.cookie = ''
// header에 authentication 추가
if (authToken) {
req.headers[CLAIM_NAME] = authToken
}
let noResultLocales: string[] = []
for (const locale of locales) {
try {
const result = await axios.get(`${MESSAGE_URL}${locale}`, {
headers: {
...req.headers,
},
})
if (result) {
const jsonstring = JSON.stringify(result.data)
await fs.writeFileSync(`${FILE_PATH}${locale}/common.json`, jsonstring)
} else {
noResultLocales.push(locale)
}
} catch (error) {
console.error('catch error', error.message)
noResultLocales.push(locale)
}
}
if (noResultLocales.length > 0) {
res
.status(500)
.json({ message: `Not Found Messages for ${noResultLocales.join(', ')}` })
} else {
res.status(200).json({ message: 'Success!!' })
}
}

View File

@@ -0,0 +1,15 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { REFRESH_TOKEN } from '@constants/env'
import Cookies from 'cookies'
/**
* refresh token 만료 시 쿠키 삭제
*/
export default (req: NextApiRequest, res: NextApiResponse) => {
const cookies = new Cookies(req, res)
// Delete the cookie by not setting a value
cookies.set(REFRESH_TOKEN)
res.status(200).json({ message: 'success' })
}

View File

@@ -0,0 +1,274 @@
import { GridButtons } from '@components/Buttons'
import Search from '@components/Search'
import CustomDataGrid from '@components/Table/CustomDataGrid'
import { GRID_PAGE_SIZE } from '@constants'
import usePage from '@hooks/usePage'
import useSearchTypes from '@hooks/useSearchType'
import { convertStringToDateFormat } from '@libs/date'
import Link from '@material-ui/core/Link'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import Typography from '@material-ui/core/Typography'
import {
GridCellParams,
GridColDef,
GridValueFormatterParams,
GridValueGetterParams,
} from '@material-ui/data-grid'
import { attachmentService, fileService } from '@service'
import { conditionAtom, errorStateSelector } from '@stores'
import { formatBytes, Page, rownum } from '@utils'
import { AxiosError } from 'axios'
import { TFunction } from 'next-i18next'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useRecoilValue, useSetRecoilState } from 'recoil'
// material-ui style
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
}),
)
type ColumnType = (
data: Page,
handleDelete: (id: string) => void,
toggoleIsDelete: (
event: React.ChangeEvent<HTMLInputElement>,
id: string,
) => void,
t: TFunction,
) => GridColDef[]
//그리드 컬럼 정의
const getColumns: ColumnType = (
data: Page,
handleDelete: (id: string) => void,
toggoleIsDelete: (
event: React.ChangeEvent<HTMLInputElement>,
id: string,
) => void,
t,
) => {
return [
{
field: 'rownum',
headerName: t('common.no'),
headerAlign: 'center',
align: 'center',
sortable: false,
valueGetter: (params: GridValueGetterParams) => {
return rownum(
data,
data?.content.findIndex(v => v.id === params.id),
'desc',
)
},
},
{
field: 'code',
headerName: t('attachment.file_id'),
headerAlign: 'center',
align: 'center',
width: 150,
sortable: false,
},
{
field: 'seq',
headerName: t('attachment.file_no'),
headerAlign: 'center',
align: 'center',
width: 100,
sortable: false,
},
{
field: 'originalFileName',
headerName: t('attachment.file_name'),
headerAlign: 'center',
align: 'left',
width: 150,
sortable: false,
renderCell: (params: GridCellParams) => (
<Typography>
<Link
href={`${fileService.downloadUrl}/${params.row.id}`}
download={params.value}
variant="body2"
>
{params.value}
</Link>
</Typography>
),
},
{
field: 'size',
headerName: t('attachment.file_size'),
headerAlign: 'center',
align: 'right',
width: 100,
sortable: false,
valueFormatter: (params: GridValueFormatterParams) => {
return formatBytes(params.value as number)
},
},
{
field: 'downloadCnt',
headerName: t('attachment.download_count'),
headerAlign: 'center',
align: 'right',
width: 100,
sortable: false,
},
{
field: 'createDate',
headerName: t('common.created_datetime'),
headerAlign: 'center',
align: 'center',
width: 120,
sortable: false,
valueFormatter: (params: GridValueFormatterParams) => {
return convertStringToDateFormat(
params.value as string,
'yyyy-MM-dd HH:mm',
)
},
},
{
field: 'isDelete',
headerName: t('common.delete_at'),
headerAlign: 'center',
align: 'center',
width: 120,
sortable: false,
renderCell: (params: GridCellParams) => (
<Switch
checked={Boolean(params.value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
toggoleIsDelete(event, params.row.id)
}
/>
),
},
{
field: 'id',
headerName: t('common.manage'),
headerAlign: 'center',
align: 'center',
width: 200,
sortable: false,
renderCell: (params: GridCellParams) => (
<GridButtons
id={params.value as string}
handleDelete={(id: string) => handleDelete(id)}
/>
),
},
]
}
const conditionKey = 'attachment'
const Attachment = () => {
const classes = useStyles()
const { t } = useTranslation()
//조회조건 상태관리
const keywordState = useRecoilValue(conditionAtom(conditionKey))
// 에러 상태관리
const setErrorState = useSetRecoilState(errorStateSelector)
// pagination 상태관리
const { page, setPageValue } = usePage(conditionKey)
//조회조건 select items
const searchTypes = useSearchTypes([
{
key: 'id',
label: t('attachment.file_id'),
},
{
key: 'name',
label: t('attachment.file_name'),
},
])
//목록 데이터 조회 및 관리
const { data, mutate } = attachmentService.search({
keywordType: keywordState?.keywordType || 'id',
keyword: keywordState?.keyword || '',
size: GRID_PAGE_SIZE,
page,
})
//에러 callback
const errorCallback = useCallback((error: AxiosError) => {
setErrorState({
error,
})
}, [])
//삭제여부 toggle 시 바로 update
const toggoleIsDelete = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>, id: string) => {
attachmentService.updateToggle({
callback: mutate,
errorCallback,
id,
isDelete: event.target.checked,
})
},
[page],
)
const handleDelete = useCallback((id: string) => {
attachmentService.delete({
id,
callback: mutate,
errorCallback,
})
}, [])
// 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const columns = useMemo(() => {
return getColumns(data, handleDelete, toggoleIsDelete, t)
}, [data])
//목록 조회
const handleSearch = () => {
if (page === 0) {
mutate()
} else {
setPageValue(0)
}
}
//datagrid page change event
const handlePageChange = (page: number, details?: any) => {
setPageValue(page)
}
return (
<div className={classes.root}>
<Search
keywordTypeItems={searchTypes}
handleSearch={handleSearch}
conditionKey={conditionKey}
/>
<CustomDataGrid
classes={classes}
rows={data?.content}
columns={columns}
rowCount={data?.totalElements}
paginationMode="server"
pageSize={GRID_PAGE_SIZE}
page={page}
onPageChange={handlePageChange}
/>
</div>
)
}
export default Attachment

View File

@@ -0,0 +1,40 @@
import LoginForm, { loginFormType } from '@components/Auth/LoginForm'
import Loader from '@components/Loader'
import { DEFAULT_ERROR_MESSAGE } from '@constants'
import useUser from '@hooks/useUser'
import Router from 'next/router'
import React, { useEffect, useState } from 'react'
import { loginSerivce } from 'src/service/Login'
const Login = () => {
const { isLogin, loggedOut, mutate } = useUser()
const [loginError, setLoginError] = useState<string | null>(null)
useEffect(() => {
if (isLogin && !loggedOut) {
Router.replace('/')
}
}, [isLogin, loggedOut])
if (isLogin) {
return <Loader />
}
const onLoginSubmit = async (form: loginFormType) => {
try {
const result = await loginSerivce.login(form)
if (result === 'success') {
mutate()
} else {
setLoginError(result)
}
} catch (error) {
console.error('login error ', error)
setLoginError(error.response?.data.message || DEFAULT_ERROR_MESSAGE)
}
}
return <LoginForm handleLogin={onLoginSubmit} errorMessage={loginError} />
}
export default Login

View File

@@ -0,0 +1,30 @@
import { ACCESS_TOKEN, AUTH_USER_ID, REFRESH_TOKEN } from '@constants/env'
import axios from 'axios'
function Logout() {
axios.defaults.headers.common[ACCESS_TOKEN] = ''
axios.defaults.headers.common[AUTH_USER_ID] = ''
return (
<div>
<a href="/auth/logout">Logout</a>
</div>
)
}
Logout.getInitialProps = ({ req, res }) => {
if (!process.browser) {
const Cookies = require('cookies')
const cookies = new Cookies(req, res)
// Delete the cookie by not setting a value
cookies.set(REFRESH_TOKEN)
cookies.set(ACCESS_TOKEN)
res.writeHead(307, { Location: '/' })
res.end()
} else {
return {}
}
}
export default Logout

View File

@@ -0,0 +1,327 @@
import { DetailButtons } from '@components/Buttons'
import ValidationAlert from '@components/EditForm/ValidationAlert'
import { FormControl, InputLabel } from '@material-ui/core'
import Box from '@material-ui/core/Box'
import Grid from '@material-ui/core/Grid'
import MenuItem from '@material-ui/core/MenuItem'
import Select from '@material-ui/core/Select'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import TextField from '@material-ui/core/TextField'
import {
AuthorizationSavePayload,
authorizationService,
codeService,
ICode,
} from '@service'
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
import { format } from '@utils'
import { AxiosError } from 'axios'
import { GetServerSideProps } from 'next'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React from 'react'
import { Controller, FormProvider, useForm } from 'react-hook-form'
import { useSetRecoilState } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginTop: theme.spacing(1),
'& .MuiOutlinedInput-input': {
padding: theme.spacing(2),
},
},
formControl: {
width: '100%',
},
switch: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
},
buttonContainer: {
display: 'flex',
margin: theme.spacing(1),
justifyContent: 'center',
'& .MuiButton-root': {
margin: theme.spacing(1),
},
},
backdrop: {
zIndex: theme.zIndex.drawer + 1,
color: '#fff',
},
}),
)
interface IAuthorizationFormInput {
authorizationName: string
urlPatternValue: string
httpMethodCode: string
sortSeq: number
}
export interface IAuthorizationItemsProps {
authorizationNo: string
initData: AuthorizationSavePayload | null
httpMethodCodeList: ICode[]
}
const AuthorizationItem = ({
authorizationNo,
initData,
httpMethodCodeList,
}: IAuthorizationItemsProps) => {
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
// 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
// 상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
// form hook
const methods = useForm<IAuthorizationFormInput>({
defaultValues: {
authorizationName: initData?.authorizationName || '',
urlPatternValue: initData?.urlPatternValue || '',
httpMethodCode: initData?.httpMethodCode || 'GET',
sortSeq: initData?.sortSeq || 0,
},
})
const {
formState: { errors },
control,
handleSubmit,
} = methods
const successCallback = () => {
setSuccessSnackBar('success')
route.back()
}
const errorCallback = (error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
}
// handleSubmit 저장
const handleSave = async (formData: IAuthorizationFormInput) => {
setSuccessSnackBar('loading')
const saved: AuthorizationSavePayload = {
authorizationName: formData.authorizationName,
urlPatternValue: formData.urlPatternValue,
httpMethodCode: formData.httpMethodCode,
sortSeq: formData.sortSeq,
}
if (authorizationNo === '-1') {
await authorizationService.save({
callback: successCallback,
errorCallback,
data: saved,
})
} else {
await authorizationService.update({
authorizationNo,
callback: successCallback,
errorCallback,
data: saved,
})
}
}
return (
<div className={classes.root}>
<FormProvider {...methods}>
<form>
<Grid container spacing={1}>
<Grid item xs={12} sm={12}>
<Box boxShadow={1}>
<Controller
name="authorizationName"
control={control}
rules={{ required: true, maxLength: 50 }}
render={({ field }) => (
<TextField
autoFocus
label={t('authorization.authorization_name')}
name="authorizationName"
required
inputProps={{ maxLength: 50 }}
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('authorization.authorization_name'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.authorizationName && (
<ValidationAlert
fieldError={errors.authorizationName}
target={[50]}
label={t('authorization.authorization_name')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={12}>
<Box boxShadow={1}>
<Controller
name="urlPatternValue"
control={control}
rules={{ required: true, maxLength: 200 }}
render={({ field }) => (
<TextField
label={t('authorization.url_pattern_value')}
name="urlPatternValue"
required
inputProps={{ maxLength: 200 }}
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('authorization.url_pattern_value'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.urlPatternValue && (
<ValidationAlert
fieldError={errors.urlPatternValue}
target={[200]}
label={t('authorization.url_pattern_value')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl variant="outlined" className={classes.formControl}>
<InputLabel id="httpMethodCode-label" required>
{t('authorization.http_method_code')}
</InputLabel>
<Controller
name="httpMethodCode"
control={control}
defaultValue={initData?.httpMethodCode || 'GET'}
rules={{ required: true }}
render={({ field }) => (
<Select
variant="outlined"
name="httpMethodCode"
required
labelId="httpMethodCode-label"
label={t('authorization.http_method_code')}
margin="dense"
{...field}
>
{httpMethodCodeList.map(option => (
<MenuItem key={option.codeId} value={option.codeId}>
{option.codeName}
</MenuItem>
))}
</Select>
)}
/>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<Box boxShadow={1}>
<Controller
name="sortSeq"
control={control}
rules={{ required: true, min: 1, max: 99999 }}
render={({ field }) => (
<TextField
label={t('common.sort_seq')}
name="sortSeq"
required
type="number"
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('common.sort_seq'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.sortSeq && (
<ValidationAlert
fieldError={errors.sortSeq}
target={[1, 99999]}
label={t('common.sort_seq')}
/>
)}
</Box>
</Grid>
</Grid>
</form>
</FormProvider>
<DetailButtons
handleList={() => {
route.back()
}}
handleSave={handleSubmit(handleSave)}
/>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
const authorizationNo = query.id
let data = {}
let httpMethodCodeList = []
try {
const codeList = await codeService.getCodeDetailList('http_method_code')
if (codeList) {
httpMethodCodeList = (await codeList.data) as ICode[]
}
if (authorizationNo === '-1') {
const result = await authorizationService.getNextSortSeq()
if (result) {
const nextSortSeq = (await result.data) as number
data = { sortSeq: nextSortSeq }
}
} else {
const result = await authorizationService.get(authorizationNo as string)
if (result) {
data = (await result.data) as AuthorizationSavePayload
}
}
} catch (error) {
console.error(`authorization item query error ${error.message}`)
if (error.response?.data?.code === 'E003') {
return {
notFound: true,
}
}
}
return {
props: {
authorizationNo,
initData: data,
httpMethodCodeList,
},
}
}
export default AuthorizationItem

View File

@@ -0,0 +1,251 @@
import { GridButtons } from '@components/Buttons'
import Search, { IKeywordType } from '@components/Search'
// 내부 컴포넌트 및 custom hook, etc...
import CustomDataGrid from '@components/Table/CustomDataGrid'
import { GRID_PAGE_SIZE } from '@constants'
import usePage from '@hooks/usePage'
// material-ui deps
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import {
GridCellParams,
GridColDef,
GridValueGetterParams,
} from '@material-ui/data-grid'
// api
import { authorizationService } from '@service'
import {
conditionAtom,
detailButtonsSnackAtom,
errorStateSelector,
} from '@stores'
import { Page, rownum } from '@utils'
import { AxiosError } from 'axios'
import { NextPage } from 'next'
import { TFunction, useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useCallback, useMemo } from 'react'
// 상태관리 recoil
import { useRecoilValue, useSetRecoilState } from 'recoil'
// material-ui style
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
}),
)
// 그리드 컬럼 정의
type ColumnsType = (
data: Page,
deleteAuthorization: (authorizationNo: string) => void,
updateAuthorization: (authorizationNo: string) => void,
t?: TFunction,
) => GridColDef[]
const getColumns: ColumnsType = (
data,
deleteAuthorization,
updateAuthorization,
t,
) => [
{
field: 'rownum',
headerName: t('common.no'),
headerAlign: 'center',
align: 'center',
width: 80,
sortable: false,
valueGetter: (params: GridValueGetterParams) =>
rownum(data, params.api.getRowIndex(params.id), 'asc'),
},
{
field: 'authorizationName',
headerName: t('authorization.authorization_name'),
headerAlign: 'center',
align: 'left',
width: 250,
sortable: false,
},
{
field: 'urlPatternValue',
headerName: t('authorization.url_pattern_value'),
headerAlign: 'center',
align: 'left',
flex: 1,
sortable: false,
},
{
field: 'httpMethodCode',
headerName: t('authorization.url_pattern_value'),
headerAlign: 'center',
align: 'center',
width: 140,
sortable: false,
},
{
field: 'sortSeq',
headerName: t('common.sort_seq'),
headerAlign: 'center',
align: 'center',
width: 110,
sortable: false,
},
{
field: 'buttons',
headerName: t('common.manage'),
headerAlign: 'center',
align: 'center',
width: 150,
sortable: false,
renderCell: function renderCellButtons(params: GridCellParams) {
return (
<GridButtons
id={params.row.authorizationNo as string}
handleDelete={deleteAuthorization}
handleUpdate={updateAuthorization}
/>
)
},
},
]
const conditionKey = 'authorization'
// 실제 render되는 컴포넌트
const Authorization: NextPage<any> = () => {
// props 및 전역변수
// const { id } = props
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
// 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
// 조회조건 select items
const searchTypes: IKeywordType[] = [
{
key: 'authorizationName',
label: t('authorization.authorization_name'),
},
{
key: 'urlPatternValue',
label: t('authorization.url_pattern_value'),
},
{
key: 'httpMethodCode',
label: t('authorization.http_method_code'),
},
]
/**
* 상태관리 필요한 훅
*/
// 조회조건 상태관리
const keywordState = useRecoilValue(conditionAtom(conditionKey))
const setErrorState = useSetRecoilState(errorStateSelector)
// 현 페이지내 필요한 hook
const { page, setPageValue } = usePage(conditionKey)
// 목록 데이터 조회 및 관리
const { data, mutate } = authorizationService.search({
keywordType: keywordState?.keywordType || 'authorizationName',
keyword: keywordState?.keyword || '',
size: GRID_PAGE_SIZE,
page,
})
/**
* 비지니스 로직
*/
// 에러 callback
const errorCallback = useCallback(
(error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
},
[setErrorState, setSuccessSnackBar],
)
// 삭제
const deleteAuthorization = useCallback(
(authorizationNo: string) => {
setSuccessSnackBar('loading')
authorizationService.delete({
authorizationNo,
callback: () => {
setSuccessSnackBar('success')
mutate()
},
errorCallback,
})
},
[errorCallback, mutate, setSuccessSnackBar],
)
// 수정 시 상세 화면 이동
const updateAuthorization = useCallback(
(authorizationNo: string) => {
route.push(`/authorization/${authorizationNo}`)
},
[route],
)
// 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const columns = useMemo(
() => getColumns(data, deleteAuthorization, updateAuthorization, t),
[data, deleteAuthorization, updateAuthorization, t],
)
// 목록 조회
const handleSearch = () => {
if (page === 0) {
mutate()
} else {
setPageValue(0)
}
}
// datagrid page change event
const handlePageChange = (_page: number, details?: any) => {
setPageValue(_page)
}
return (
<div className={classes.root}>
<Search
keywordTypeItems={searchTypes}
handleSearch={handleSearch}
handleRegister={() => {
route.push('authorization/-1')
}}
conditionKey={conditionKey}
/>
<CustomDataGrid
page={page}
classes={classes}
rows={data?.content}
columns={columns}
rowCount={data?.totalElements}
paginationMode="server"
pageSize={GRID_PAGE_SIZE}
onPageChange={handlePageChange}
getRowId={r => r.authorizationNo}
/>
</div>
)
}
export default Authorization

View File

@@ -0,0 +1,568 @@
import AttachList from '@components/AttachList'
import { DetailButtons } from '@components/Buttons'
import CustomAlert from '@components/CustomAlert'
import ValidationAlert from '@components/EditForm/ValidationAlert'
import { Upload, UploadType } from '@components/Upload'
import Box from '@material-ui/core/Box'
import FormControl from '@material-ui/core/FormControl'
import FormControlLabel from '@material-ui/core/FormControlLabel'
import Grid from '@material-ui/core/Grid'
import InputLabel from '@material-ui/core/InputLabel'
import MenuItem from '@material-ui/core/MenuItem'
import Select from '@material-ui/core/Select'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import TextField from '@material-ui/core/TextField'
import {
BannerSavePayload,
bannerService,
codeService,
fileService,
IAttachmentResponse,
ICode,
ISite,
UploadInfoReqeust,
} from '@service'
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
import { format } from '@utils'
import { AxiosError } from 'axios'
import { GetServerSideProps } from 'next'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'
import { useSetRecoilState } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginTop: theme.spacing(1),
'& .MuiOutlinedInput-input': {
padding: theme.spacing(2),
},
},
formControl: {
width: '100%',
},
switch: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
},
switchBox: {
padding: theme.spacing(1, 0),
},
textFieldMultiline: {
padding: '0 !important',
},
buttonContainer: {
display: 'flex',
margin: theme.spacing(1),
justifyBanner: 'center',
'& .MuiButton-root': {
margin: theme.spacing(1),
},
},
backdrop: {
zIndex: theme.zIndex.drawer + 1,
color: '#fff',
},
}),
)
interface IBannerFormInput {
siteId: number
bannerTypeCode: string
bannerTitle: string
attachmentCode: string
urlAddr: string
newWindowAt: boolean
bannerContent: string
sortSeq: number
}
export interface IBannerItemsProps {
bannerNo: string
initData: BannerSavePayload | null
bannerTypeCodeList: ICode[]
sites: ISite[]
}
const BannerItem = ({
bannerNo,
initData,
bannerTypeCodeList,
sites,
}: IBannerItemsProps) => {
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
// 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
// 상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
const uploadRef = useRef<UploadType>()
const [attachData, setAttachData] = useState<
IAttachmentResponse[] | undefined
>(undefined)
// alert
const [customAlert, setCustomAlert] = useState<any>({
open: false,
message: '',
handleAlert: () => setCustomAlert({ open: false }),
})
// form hook
const methods = useForm<IBannerFormInput>({
defaultValues: {
bannerTypeCode: initData?.bannerTypeCode || '0001',
bannerTitle: initData?.bannerTitle || '',
urlAddr: initData?.urlAddr || '',
newWindowAt:
typeof initData?.newWindowAt !== 'undefined'
? initData?.newWindowAt
: false,
bannerContent: initData?.bannerContent || '',
sortSeq: initData?.sortSeq || 0,
},
})
const {
formState: { errors },
control,
handleSubmit,
setValue,
} = methods
const watchSite = useWatch({
control,
name: 'siteId',
})
useEffect(() => {
if (watchSite) {
bannerService
.getNextSortSeq(watchSite)
.then(result => {
if (result) {
setValue('sortSeq', result.data, {
shouldValidate: false,
shouldDirty: true,
})
}
})
.catch(error => {
setErrorState({
error,
})
})
}
}, [watchSite])
const successCallback = () => {
setSuccessSnackBar('success')
route.back()
}
const errorCallback = (error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
}
const getAttachments = useCallback(
async (code: string) => {
try {
const result = await fileService.getAttachmentList(code)
if (result) {
setAttachData(result.data)
}
} catch (error) {
setErrorState({
error,
})
}
},
[setErrorState],
)
useEffect(() => {
if (initData.attachmentCode) {
getAttachments(initData.attachmentCode)
}
}, [getAttachments, initData.attachmentCode])
// handleSubmit 저장
const handleSave = async (formData: IBannerFormInput) => {
setSuccessSnackBar('loading')
let { attachmentCode } = initData
const attachCount = await uploadRef.current.count(attachData)
if (attachCount === 0) {
setCustomAlert({
open: true,
message: format(t('valid.required.format'), [
t('banner.attachment_code'),
]),
handleAlert: () => {
setCustomAlert({ open: false })
},
})
setSuccessSnackBar('none')
return
}
const isUpload = await uploadRef.current.isModified(attachData)
if (isUpload) {
const info: UploadInfoReqeust = {
entityName: 'banner',
entityId: bannerNo,
}
// 업로드 및 저장
const result = await uploadRef.current.upload(info, attachData)
if (result) {
if (result !== 'no attachments' && result !== 'no update list') {
attachmentCode = result
}
}
}
const saved: BannerSavePayload = {
siteId: formData.siteId,
bannerTypeCode: formData.bannerTypeCode,
bannerTitle: formData.bannerTitle,
attachmentCode,
urlAddr: formData.urlAddr,
newWindowAt: formData.newWindowAt,
bannerContent: formData.bannerContent,
sortSeq: formData.sortSeq,
}
try {
let result
if (bannerNo === '-1') {
result = await bannerService.save({
data: saved,
})
} else {
result = await bannerService.update({
bannerNo,
data: saved,
})
}
if (result) {
successCallback()
}
} catch (error) {
errorCallback(error)
if (bannerNo === '-1') {
uploadRef.current?.rollback(attachmentCode)
}
}
}
return (
<div className={classes.root}>
<FormProvider {...methods}>
<form>
<Grid container spacing={1}>
<Grid item xs={12} sm={12}>
<FormControl variant="outlined" className={classes.formControl}>
<InputLabel id="banner-site-label" required>
{t('menu.site')}
</InputLabel>
<Controller
name="siteId"
control={control}
defaultValue={initData?.siteId || sites[0]?.id}
rules={{ required: true }}
render={({ field }) => (
<Select
variant="outlined"
name="siteId"
required
labelId="site-label"
label={t('menu.site')}
margin="dense"
{...field}
>
{sites?.map(option => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
)}
/>
</FormControl>
</Grid>
<Grid item xs={12} sm={12}>
<FormControl variant="outlined" className={classes.formControl}>
<InputLabel id="bannerTypeCode-label" required>
{t('banner.banner_type_code')}
</InputLabel>
<Controller
name="bannerTypeCode"
control={control}
defaultValue={initData?.bannerTypeCode || '0001'}
rules={{ required: true }}
render={({ field }) => (
<Select
variant="outlined"
name="bannerTypeCode"
required
labelId="bannerTypeCode-label"
label={t('banner.banner_type_code')}
margin="dense"
{...field}
>
{bannerTypeCodeList.map(option => (
<MenuItem key={option.codeId} value={option.codeId}>
{option.codeName}
</MenuItem>
))}
</Select>
)}
/>
</FormControl>
</Grid>
<Grid item xs={12} sm={12}>
<Box boxShadow={1}>
<Controller
name="bannerTitle"
control={control}
rules={{ required: true, maxLength: 100 / 2 }}
render={({ field }) => (
<TextField
label={t('banner.banner_title')}
name="bannerTitle"
required
inputProps={{ maxLength: 100 / 2 }}
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('banner.banner_title'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.bannerTitle && (
<ValidationAlert
fieldError={errors.bannerTitle}
target={[100 / 2]}
label={t('banner.banner_title')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={12}>
<Box boxShadow={1}>
<Upload
accept={'image/*'}
ref={uploadRef}
uploadLimitCount={1}
attachmentCode={initData.attachmentCode}
attachData={attachData}
/>
{attachData && (
<AttachList data={attachData} setData={setAttachData} />
)}
</Box>
</Grid>
<Grid item xs={12} sm={9}>
<Box boxShadow={1}>
<Controller
name="urlAddr"
control={control}
rules={{ required: true, maxLength: 500 }}
render={({ field }) => (
<TextField
label={t('common.url')}
name="urlAddr"
inputProps={{ maxLength: 500 }}
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('common.url'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.urlAddr && (
<ValidationAlert
fieldError={errors.urlAddr}
target={[500]}
label={t('common.url')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={3}>
<Box boxShadow={1} className={classes.switchBox}>
<FormControlLabel
label={t('banner.new_window_at')}
labelPlacement="start"
control={
<Controller
name="newWindowAt"
control={control}
render={({ field: { onChange, ref, value } }) => (
<Switch
inputProps={{ 'aria-label': 'secondary checkbox' }}
onChange={onChange}
inputRef={ref}
checked={value}
/>
)}
/>
}
/>
</Box>
</Grid>
<Grid item xs={12} sm={12}>
<Box boxShadow={1}>
<Controller
name="bannerContent"
control={control}
rules={{ maxLength: 2000 }}
render={({ field }) => (
<TextField
label={t('banner.banner_content')}
name="bannerContent"
inputProps={{
maxLength: 2000,
className: classes.textFieldMultiline,
}}
multiline
minRows={10}
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('banner.banner_content'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.bannerContent && (
<ValidationAlert
fieldError={errors.bannerContent}
target={[2000]}
label={t('banner.banner_content')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={12}>
<Box boxShadow={1}>
<Controller
name="sortSeq"
control={control}
rules={{ required: true, min: 1, max: 99999 }}
render={({ field }) => (
<TextField
label={t('common.sort_seq')}
name="sortSeq"
required
type="number"
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('common.sort_seq'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.sortSeq && (
<ValidationAlert
fieldError={errors.sortSeq}
target={[1, 99999]}
label={t('common.sort_seq')}
/>
)}
</Box>
</Grid>
</Grid>
</form>
</FormProvider>
<DetailButtons
handleList={() => {
route.back()
}}
handleSave={handleSubmit(handleSave)}
/>
<CustomAlert
contentText={customAlert.message}
open={customAlert.open}
handleAlert={() => setCustomAlert({ open: false })}
/>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
const bannerNo = query.id as string
let bannerTypeCodeList = []
let data = {}
let sites: ISite[] = []
try {
sites = await bannerService.getSites()
const codeList = await codeService.getCodeDetailList('banner_type_code')
if (codeList) {
bannerTypeCodeList = (await codeList.data) as ICode[]
}
if (bannerNo === '-1') {
const result = await bannerService.getNextSortSeq(sites[0].id)
if (result) {
const nextSortSeq = (await result.data) as number
data = { sortSeq: nextSortSeq }
}
} else {
const result = await bannerService.get(bannerNo)
if (result) {
data = (await result.data) as BannerSavePayload
}
}
} catch (error) {
console.error(`banner item query error ${error.message}`)
if (error.response?.data?.code === 'E003') {
return {
notFound: true,
}
}
}
return {
props: {
bannerNo,
initData: data,
bannerTypeCodeList,
sites,
},
}
}
export default BannerItem

View File

@@ -0,0 +1,361 @@
import { GridButtons } from '@components/Buttons'
import Search, { IKeywordType } from '@components/Search'
import CustomDataGrid from '@components/Table/CustomDataGrid'
import { GRID_PAGE_SIZE } from '@constants'
import usePage from '@hooks/usePage'
// 내부 컴포넌트 및 custom hook, etc...
import { convertStringToDateFormat } from '@libs/date'
import Box from '@material-ui/core/Box'
import MenuItem from '@material-ui/core/MenuItem'
// material-ui deps
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import TextField from '@material-ui/core/TextField'
import {
GridCellParams,
GridColDef,
GridValueFormatterParams,
GridValueGetterParams,
} from '@material-ui/data-grid'
// api
import { bannerService, ISite } from '@service'
import {
conditionAtom,
conditionValue,
detailButtonsSnackAtom,
errorStateSelector,
} from '@stores'
import { Page, rownum } from '@utils'
import { AxiosError } from 'axios'
import { GetServerSideProps, NextPage } from 'next'
import { TFunction, useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useCallback, useMemo, useState } from 'react'
// 상태관리 recoil
import { useRecoilValue, useSetRecoilState } from 'recoil'
// material-ui style
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
search: {
padding: theme.spacing(1),
textAlign: 'center',
width: '18vw',
minWidth: 80,
maxWidth: 200,
},
}),
)
// 그리드 컬럼 정의
type ColumnsType = (
data: Page,
toggleUseAt,
deleteBanner: (bannerNo: string) => void,
updateBanner: (bannerNo: string) => void,
t?: TFunction,
) => GridColDef[]
const getColumns: ColumnsType = (
data,
toggleUseAt,
deleteBanner,
updateBanner,
t,
) => [
{
field: 'rownum',
headerName: t('common.no'),
headerAlign: 'center',
align: 'center',
width: 80,
sortable: false,
valueGetter: (params: GridValueGetterParams) =>
rownum(data, params.api.getRowIndex(params.id), 'asc'),
},
{
field: 'siteName',
headerName: t('menu.site'),
headerAlign: 'center',
align: 'center',
width: 80,
sortable: false,
},
{
field: 'bannerTypeCodeName',
headerName: t('banner.banner_type_code'),
headerAlign: 'center',
align: 'center',
width: 120,
sortable: false,
},
{
field: 'bannerTitle',
headerName: t('banner.banner_title'),
headerAlign: 'center',
align: 'left',
flex: 1,
sortable: false,
},
{
field: 'useAt',
headerName: t('common.use_at'),
headerAlign: 'center',
align: 'center',
width: 150,
sortable: false,
renderCell: function renderCellCreatedAt(params: GridCellParams) {
return (
<Switch
checked={Boolean(params.value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
toggleUseAt(event, params.row.bannerNo)
}
/>
)
},
},
{
field: 'createdDate',
headerName: t('common.created_datetime'),
headerAlign: 'center',
align: 'center',
width: 200,
sortable: false,
valueFormatter: (params: GridValueFormatterParams) =>
convertStringToDateFormat(params.value as string, 'yyyy-MM-dd HH:mm:ss'),
},
{
field: 'buttons',
headerName: t('common.manage'),
headerAlign: 'center',
align: 'center',
width: 150,
sortable: false,
renderCell: function renderCellButtons(params: GridCellParams) {
return (
<GridButtons
id={params.row.bannerNo as string}
handleDelete={deleteBanner}
handleUpdate={updateBanner}
/>
)
},
},
]
const conditionKey = 'banner'
interface BannerProps {
sites: ISite[]
}
// 실제 render되는 컴포넌트
const Banner: NextPage<BannerProps> = ({ sites }) => {
// props 및 전역변수
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
// 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
// 조회조건 select items
const searchTypes: IKeywordType[] = [
{
key: 'bannerTitle',
label: t('banner.banner_title'),
},
{
key: 'bannerContent',
label: t('banner.banner_content'),
},
]
/**
* 상태관리 필요한 훅
*/
// 조회조건 상태관리
const keywordState = useRecoilValue(conditionAtom(conditionKey))
const setErrorState = useSetRecoilState(errorStateSelector)
// 현 페이지내 필요한 hook
const { page, setPageValue } = usePage(conditionKey)
const [customKeyword, setCustomKeyword] = useState<conditionValue>({
siteId: keywordState?.siteId || '-',
})
// 목록 데이터 조회 및 관리
const { data, mutate } = bannerService.search({
keywordType: keywordState?.keywordType || 'bannerName',
keyword: keywordState?.keyword || '',
siteId: keywordState?.siteId === '-' ? '' : keywordState?.siteId,
size: GRID_PAGE_SIZE,
page,
})
/**
* 비지니스 로직
*/
// 에러 callback
const errorCallback = useCallback(
(error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
},
[setErrorState, setSuccessSnackBar],
)
// 성공 callback
const successCallback = useCallback(() => {
setSuccessSnackBar('success')
mutate()
}, [mutate, setSuccessSnackBar])
// 사용 여부 toggle 시 save
const toggleUseAt = useCallback(
async (
event: React.ChangeEvent<HTMLInputElement>,
paramBannerNo: string,
) => {
setSuccessSnackBar('loading')
await bannerService.updateUseAt({
callback: successCallback,
errorCallback,
bannerNo: paramBannerNo,
useAt: event.target.checked,
})
},
[errorCallback, setSuccessSnackBar, successCallback],
)
// 삭제
const deleteBanner = useCallback(
(bannerNo: string) => {
setSuccessSnackBar('loading')
bannerService.delete({
bannerNo,
callback: successCallback,
errorCallback,
})
},
[errorCallback, setSuccessSnackBar, successCallback],
)
// 수정 시 상세 화면 이동
const updateBanner = useCallback(
(bannerNo: string) => {
route.push(`/banner/${bannerNo}`)
},
[route],
)
// 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const columns = useMemo(
() => getColumns(data, toggleUseAt, deleteBanner, updateBanner, t),
[data, toggleUseAt, deleteBanner, updateBanner, t],
)
// 목록 조회
const handleSearch = () => {
if (page === 0) {
mutate()
} else {
setPageValue(0)
}
}
// datagrid page change event
const handlePageChange = (_page: number, details?: any) => {
setPageValue(_page)
}
// 조회조건 select onchange
const handleSiteIdChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event.preventDefault()
setCustomKeyword({
siteId: event.target.value,
})
}
return (
<div className={classes.root}>
<Search
keywordTypeItems={searchTypes}
handleSearch={handleSearch}
handleRegister={() => {
route.push('banner/-1')
}}
conditionKey={conditionKey}
customKeyword={customKeyword}
conditionNodes={
<Box className={classes.search}>
<TextField
id="select-parentCodeId"
select
value={customKeyword?.siteId}
onChange={handleSiteIdChange}
variant="outlined"
fullWidth
>
<MenuItem key="-" value="-">
{t('common.all')}
</MenuItem>
{sites.map(option => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
))}
</TextField>
</Box>
}
/>
<CustomDataGrid
page={page}
classes={classes}
rows={data?.content}
columns={columns}
rowCount={data?.totalElements}
paginationMode="server"
pageSize={GRID_PAGE_SIZE}
onPageChange={handlePageChange}
getRowId={r => r.bannerNo}
/>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async context => {
let sites: ISite[] = []
try {
const result = await bannerService.getSites()
if (sites) {
sites = result
}
} catch (error) {
console.error(`banner site getServerSideProps error ${error.message}`)
}
return {
props: {
sites,
},
}
}
export default Banner

View File

@@ -0,0 +1,585 @@
import { DetailButtons } from '@components/Buttons'
import ValidationAlert from '@components/EditForm/ValidationAlert'
import Box from '@material-ui/core/Box'
import FormControl from '@material-ui/core/FormControl'
import FormControlLabel from '@material-ui/core/FormControlLabel'
import Grid from '@material-ui/core/Grid'
import InputLabel from '@material-ui/core/InputLabel'
import MenuItem from '@material-ui/core/MenuItem'
import Select from '@material-ui/core/Select'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import TextField from '@material-ui/core/TextField'
import {
BoardSavePayload,
boardService,
codeService,
ICode,
SKINT_TYPE_CODE_NORMAL,
} from '@service'
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
import { format } from '@utils'
import { AxiosError } from 'axios'
import { GetServerSideProps } from 'next'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useState } from 'react'
import { Controller, FormProvider, useForm } from 'react-hook-form'
import { useSetRecoilState } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginTop: theme.spacing(1),
'& .MuiOutlinedInput-input': {
padding: theme.spacing(2),
},
},
formControl: {
width: '100%',
},
switchBox: {
padding: theme.spacing(1, 0),
},
switch: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
},
buttonContainer: {
display: 'flex',
margin: theme.spacing(1),
justifyBoard: 'center',
'& .MuiButton-root': {
margin: theme.spacing(1),
},
},
backdrop: {
zIndex: theme.zIndex.drawer + 1,
color: '#fff',
},
}),
)
interface IBoardFormInput {
boardName: string
skinTypeCode: string
titleDisplayLength: number
postDisplayCount: number
pageDisplayCount: number
newDisplayDayCount: number
editorUseAt: boolean
userWriteAt: boolean
commentUseAt: boolean
uploadUseAt: boolean
uploadLimitCount: number
uploadLimitSize: number
}
export interface IBoardItemsProps {
boardNo: number
initData: BoardSavePayload | null
skinTypeCodeList?: ICode[]
}
const BoardItem = ({
boardNo,
initData,
skinTypeCodeList,
}: IBoardItemsProps) => {
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
// 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
// 상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
const [uploadUseAt, setUploadUseAt] = useState<boolean>(
initData?.uploadUseAt !== undefined ? initData?.uploadUseAt : false,
)
// form hook
const methods = useForm<IBoardFormInput>({
defaultValues: {
boardName: initData?.boardName || '',
skinTypeCode: initData?.skinTypeCode || SKINT_TYPE_CODE_NORMAL,
titleDisplayLength: initData?.titleDisplayLength,
postDisplayCount: initData?.postDisplayCount,
pageDisplayCount: initData?.pageDisplayCount,
newDisplayDayCount: initData?.newDisplayDayCount,
editorUseAt:
typeof initData?.editorUseAt !== 'undefined'
? initData?.editorUseAt
: false,
userWriteAt:
typeof initData?.userWriteAt !== 'undefined'
? initData?.userWriteAt
: false,
commentUseAt:
typeof initData?.commentUseAt !== 'undefined'
? initData?.commentUseAt
: false,
uploadUseAt:
typeof initData?.uploadUseAt !== 'undefined'
? initData?.uploadUseAt
: false,
uploadLimitCount: initData?.uploadLimitCount,
uploadLimitSize: initData?.uploadLimitSize,
},
})
const {
formState: { errors },
control,
handleSubmit,
} = methods
const successCallback = () => {
setSuccessSnackBar('success')
route.back()
}
const errorCallback = (error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
}
// handleSubmit 저장
const handleSave = async (formData: IBoardFormInput) => {
setSuccessSnackBar('loading')
const saved: BoardSavePayload = {
boardName: formData.boardName,
skinTypeCode: formData.skinTypeCode,
titleDisplayLength: formData.titleDisplayLength,
postDisplayCount: formData.postDisplayCount,
pageDisplayCount: formData.pageDisplayCount,
newDisplayDayCount: formData.newDisplayDayCount,
editorUseAt: formData.editorUseAt,
userWriteAt: formData.userWriteAt,
commentUseAt: formData.commentUseAt,
uploadUseAt: formData.uploadUseAt,
uploadLimitCount: formData.uploadUseAt ? formData.uploadLimitCount : null,
uploadLimitSize: formData.uploadUseAt ? formData.uploadLimitSize : null,
}
if (boardNo === -1) {
await boardService.save({
callback: successCallback,
errorCallback,
data: saved,
})
} else {
await boardService.update({
boardNo,
callback: successCallback,
errorCallback,
data: saved,
})
}
}
const handleChangeUploadUseAt = event => {
setUploadUseAt(event.target.checked)
}
const getSwitch = (onChange, ref, value) => (
<Switch
inputProps={{ 'aria-label': 'secondary checkbox' }}
onChange={onChange}
inputRef={ref}
checked={value}
/>
)
return (
<div className={classes.root}>
<FormProvider {...methods}>
<form>
<Grid container spacing={1}>
<Grid item xs={12} sm={6}>
<Box boxShadow={1}>
<Controller
name="boardName"
control={control}
rules={{ required: true, maxLength: 100 }}
render={({ field }) => (
<TextField
autoFocus
label={t('board.board_name')}
name="boardName"
required
inputProps={{ maxLength: 100 }}
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('board.board_name'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.boardName && (
<ValidationAlert
fieldError={errors.boardName}
target={[100]}
label={t('board.board_name')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl variant="outlined" className={classes.formControl}>
<InputLabel id="skinTypeCode-label" required>
{t('board.skin_type_code')}
</InputLabel>
<Controller
name="skinTypeCode"
control={control}
defaultValue={initData?.skinTypeCode}
rules={{ required: true }}
render={({ field }) => (
<Select
variant="outlined"
name="skinTypeCode"
required
labelId="skinTypeCode-label"
label={t('board.skin_type_code')}
margin="dense"
{...field}
>
{skinTypeCodeList.map(option => (
<MenuItem key={option.codeId} value={option.codeId}>
{option.codeName}
</MenuItem>
))}
</Select>
)}
/>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<Box boxShadow={1}>
<Controller
name="titleDisplayLength"
control={control}
rules={{ required: true, min: 1, max: 99999 }}
render={({ field }) => (
<TextField
label={t('board.title_display_length')}
name="titleDisplayLength"
required
type="number"
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('board.title_display_length'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.titleDisplayLength && (
<ValidationAlert
fieldError={errors.titleDisplayLength}
target={[1, 99999]}
label={t('board.title_display_length')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<Box boxShadow={1}>
<Controller
name="postDisplayCount"
control={control}
rules={{ required: true, min: 1, max: 99999 }}
render={({ field }) => (
<TextField
label={t('board.post_display_count')}
name="postDisplayCount"
required
type="number"
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('board.post_display_count'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.postDisplayCount && (
<ValidationAlert
fieldError={errors.postDisplayCount}
target={[1, 99999]}
label={t('board.post_display_count')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<Box boxShadow={1}>
<Controller
name="pageDisplayCount"
control={control}
rules={{ required: true, min: 1, max: 99999 }}
render={({ field }) => (
<TextField
label={t('board.page_display_count')}
name="pageDisplayCount"
required
type="number"
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('board.page_display_count'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.pageDisplayCount && (
<ValidationAlert
fieldError={errors.pageDisplayCount}
target={[1, 99999]}
label={t('board.page_display_count')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<Box boxShadow={1}>
<Controller
name="newDisplayDayCount"
control={control}
rules={{ required: true, min: 1, max: 99999 }}
render={({ field }) => (
<TextField
label={t('board.new_display_day_count')}
name="newDisplayDayCount"
required
type="number"
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('board.new_display_day_count'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.newDisplayDayCount && (
<ValidationAlert
fieldError={errors.newDisplayDayCount}
target={[1, 99999]}
label={t('board.new_display_day_count')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<Box boxShadow={1} className={classes.switchBox}>
<FormControlLabel
label={t('board.editor_use_at')}
labelPlacement="start"
control={
<Controller
name="editorUseAt"
control={control}
render={({ field: { onChange, ref, value } }) =>
getSwitch(onChange, ref, value)
}
/>
}
/>
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<Box boxShadow={1} className={classes.switchBox}>
<FormControlLabel
label={t('board.user_write_at')}
labelPlacement="start"
control={
<Controller
name="userWriteAt"
control={control}
render={({ field: { onChange, ref, value } }) =>
getSwitch(onChange, ref, value)
}
/>
}
/>
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<Box boxShadow={1} className={classes.switchBox}>
<FormControlLabel
label={t('board.upload_use_at')}
labelPlacement="start"
control={
<Controller
name="uploadUseAt"
control={control}
render={({ field: { onChange, ref, value } }) => (
<Switch
inputProps={{ 'aria-label': 'secondary checkbox' }}
onClick={handleChangeUploadUseAt}
onChange={onChange}
inputRef={ref}
checked={value}
/>
)}
/>
}
/>
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<Box boxShadow={1} className={classes.switchBox}>
<FormControlLabel
label={t('board.comment_use_at')}
labelPlacement="start"
control={
<Controller
name="commentUseAt"
control={control}
render={({ field: { onChange, ref, value } }) =>
getSwitch(onChange, ref, value)
}
/>
}
/>
</Box>
</Grid>
<Grid item xs={12} sm={6} hidden={!uploadUseAt}>
<Box boxShadow={1}>
<Controller
name="uploadLimitCount"
control={control}
rules={{ required: uploadUseAt, min: 1, max: 99999 }}
render={({ field }) => (
<TextField
label={t('board.upload_limit_count')}
name="uploadLimitCount"
type="number"
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('board.upload_limit_count'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.uploadLimitCount && (
<ValidationAlert
fieldError={errors.uploadLimitCount}
target={[1, 99999]}
label={t('board.upload_limit_count')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={6} hidden={!uploadUseAt}>
<Box boxShadow={1}>
<Controller
name="uploadLimitSize"
control={control}
rules={{
required: uploadUseAt,
min: 1,
max: 99999999999999999999,
}}
render={({ field }) => (
<TextField
label={t('board.upload_limit_size')}
name="uploadLimitSize"
type="number"
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('board.upload_limit_size'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.uploadLimitSize && (
<ValidationAlert
fieldError={errors.uploadLimitSize}
target={[1, 99999]}
label={t('board.upload_limit_size')}
/>
)}
</Box>
</Grid>
</Grid>
</form>
</FormProvider>
<DetailButtons
handleList={() => {
route.back()
}}
handleSave={handleSubmit(handleSave)}
/>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
const boardNo = Number(query.id)
let data = {}
let skinTypeCodeList = []
try {
const codeList = await codeService.getCodeDetailList('skin_type_code')
if (codeList) {
skinTypeCodeList = (await codeList.data) as ICode[]
}
if (boardNo !== -1) {
const result = await boardService.get(boardNo)
if (result) {
data = (await result.data) as BoardSavePayload
}
}
} catch (error) {
console.error(`board item query error ${error.message}`)
if (error.response?.data?.code === 'E003') {
return {
notFound: true,
}
}
}
return {
props: {
boardNo,
initData: data,
skinTypeCodeList,
},
}
}
export default BoardItem

View File

@@ -0,0 +1,277 @@
import React, { useCallback, useMemo } from 'react'
import { AxiosError } from 'axios'
import { NextPage } from 'next'
import { useRouter } from 'next/router'
import { TFunction, useTranslation } from 'next-i18next'
// material-ui deps
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import {
GridCellParams,
GridColDef,
GridValueFormatterParams,
GridValueGetterParams,
} from '@material-ui/data-grid'
// 내부 컴포넌트 및 custom hook, etc...
import { convertStringToDateFormat } from '@libs/date'
import CustomDataGrid from '@components/Table/CustomDataGrid'
import { CustomButtons, IButtonProps } from '@components/Buttons'
import { Page, rownum } from '@utils'
import Search, { IKeywordType } from '@components/Search'
// 상태관리 recoil
import { useRecoilValue, useSetRecoilState } from 'recoil'
import {
conditionAtom,
detailButtonsSnackAtom,
errorStateSelector,
} from '@stores'
// api
import { boardService } from '@service'
import { PopupProps } from '@components/DialogPopup'
import Button from '@material-ui/core/Button'
import usePage from '@hooks/usePage'
import { GRID_PAGE_SIZE } from '@constants'
// material-ui style
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
}),
)
// 그리드 컬럼 정의
type ColumnsType = (
data: Page,
buttons: IButtonProps[],
t?: TFunction,
handlePopup?: (row: any) => void,
) => GridColDef[]
const getColumns: ColumnsType = (data, buttons, t, handlePopup) => [
{
field: 'rownum',
headerName: t('common.no'),
headerAlign: 'center',
align: 'center',
width: 80,
sortable: false,
valueGetter: (params: GridValueGetterParams) =>
rownum(data, params.api.getRowIndex(params.id), 'asc'),
},
{
field: 'boardName',
headerName: t('board.board_name'),
headerAlign: 'center',
align: 'left',
flex: 1,
sortable: false,
},
{
field: 'skinTypeCodeName',
headerName: t('board.skin_type_code'),
headerAlign: 'center',
align: 'center',
width: 150,
sortable: false,
},
{
field: 'createdDate',
headerName: t('common.created_datetime'),
headerAlign: 'center',
align: 'center',
width: 200,
sortable: false,
valueFormatter: (params: GridValueFormatterParams) =>
convertStringToDateFormat(params.value as string, 'yyyy-MM-dd HH:mm:ss'),
},
{
field: 'buttons',
headerName: t('common.manage'),
headerAlign: 'center',
align: 'center',
width: 250,
sortable: false,
renderCell: function renderCellButtons(params: GridCellParams) {
return handlePopup ? (
<Button
onClick={() => {
handlePopup(params.row)
}}
variant="outlined"
color="inherit"
size="small"
>
{t('common.select')}
</Button>
) : (
<CustomButtons buttons={buttons} row={params.row} />
)
},
},
]
const conditionKey = 'board'
export type BoardProps = PopupProps
// 실제 render되는 컴포넌트
const Board: NextPage<BoardProps> = props => {
// props 및 전역변수
const { handlePopup } = props
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
// 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
// 조회조건 select items
const searchTypes: IKeywordType[] = [
{
key: 'boardName',
label: t('board.board_name'),
},
]
/**
* 상태관리 필요한 훅
*/
// 조회조건 상태관리
const keywordState = useRecoilValue(conditionAtom(conditionKey))
const setErrorState = useSetRecoilState(errorStateSelector)
// 현 페이지내 필요한 hook
const { page, setPageValue } = usePage(conditionKey)
// 목록 데이터 조회 및 관리
const { data, mutate } = boardService.search({
keywordType: keywordState?.keywordType || 'boardName',
keyword: keywordState?.keyword || '',
size: GRID_PAGE_SIZE,
page,
})
/**
* 비지니스 로직
*/
// 에러 callback
const errorCallback = useCallback(
(error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
},
[setErrorState, setSuccessSnackBar],
)
// 삭제
const handleDelete = useCallback(
(row: any) => {
const { boardNo } = row
setSuccessSnackBar('loading')
boardService.delete({
boardNo,
callback: () => {
setSuccessSnackBar('success')
mutate()
},
errorCallback,
})
},
[errorCallback, mutate, setSuccessSnackBar],
)
// 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const columns = useMemo(
() =>
getColumns(
data,
[
{
label: `${t('posts')} ${t('common.manage')}`,
variant: 'outlined',
size: 'small',
handleButton: (row: any) => {
route.push(`/posts/${row.boardNo}`)
},
},
{
label: t('label.button.edit'),
variant: 'outlined',
color: 'primary',
size: 'small',
handleButton: (row: any) => {
route.push(`/board/${row.boardNo}`)
},
},
{
label: t('label.button.delete'),
variant: 'outlined',
color: 'secondary',
size: 'small',
confirmMessage: t('msg.confirm.delete'),
handleButton: handleDelete,
completeMessage: t('msg.success.delete'),
},
],
t,
handlePopup,
),
[data, t, handleDelete, handlePopup, route],
)
// 목록 조회
const handleSearch = () => {
if (page === 0) {
mutate()
} else {
setPageValue(0)
}
}
// datagrid page change event
const handlePageChange = (_page: number, details?: any) => {
setPageValue(_page)
}
const handleRegister = () => {
route.push('board/-1')
}
return (
<div className={classes.root}>
<Search
keywordTypeItems={searchTypes}
handleSearch={handleSearch}
handleRegister={handlePopup ? undefined : handleRegister}
conditionKey={conditionKey}
/>
<CustomDataGrid
page={page}
classes={classes}
rows={data?.content}
columns={columns}
rowCount={data?.totalElements}
paginationMode="server"
pageSize={GRID_PAGE_SIZE}
onPageChange={handlePageChange}
getRowId={r => r.boardNo}
/>
</div>
)
}
export default Board

View File

@@ -0,0 +1,293 @@
import { DetailButtons } from '@components/Buttons'
import ValidationAlert from '@components/EditForm/ValidationAlert'
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 Grid from '@material-ui/core/Grid'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import TextField from '@material-ui/core/TextField'
import { CodeSavePayload, codeService } from '@service'
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
import { AxiosError } from 'axios'
import { GetServerSideProps } from 'next'
import { useRouter } from 'next/router'
import React from 'react'
import { Controller, FormProvider, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { useSetRecoilState } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
},
content: {
padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`,
},
switch: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
},
}),
)
interface ICodeFormInput {
codeId: string
codeName: string
codeDescription: string
sortSeq: number
useAt: boolean
}
export interface ICodeItemsProps {
id: string
initData: CodeSavePayload | null
}
const CodeItem = ({ id, initData }: ICodeItemsProps) => {
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
//상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
//form hook
const methods = useForm<ICodeFormInput>({
defaultValues: {
codeId: initData?.codeId || '',
codeName: initData?.codeName || '',
codeDescription: initData?.codeDescription || '',
sortSeq: initData?.sortSeq || 0,
useAt: typeof initData?.useAt !== 'undefined' ? initData?.useAt : true,
},
})
const {
formState: { errors },
control,
handleSubmit,
} = methods
// 코드ID disabled
const disabled = id !== '-1'
// <목록, 저장> 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
const successCallback = () => {
setSuccessSnackBar('success')
route.back()
}
const errorCallback = (error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
}
//onsubmit 저장
const onSubmit = async (formData: ICodeFormInput) => {
setSuccessSnackBar('loading')
const saved: CodeSavePayload = {
codeId: formData.codeId,
codeName: formData.codeName,
codeDescription: formData.codeDescription,
useAt: formData.useAt,
sortSeq: formData.sortSeq,
}
if (id === '-1') {
codeService.save({
callback: successCallback,
errorCallback,
data: saved,
})
} else {
codeService.update({
id,
callback: successCallback,
errorCallback,
data: saved,
})
}
}
return (
<>
<FormProvider {...methods}>
<Grid container spacing={1}>
<Grid item xs={12} sm={6}>
<Card className={classes.root}>
<CardHeader title={t('code.title')} />
<Divider />
<CardContent className={classes.content}>
<Controller
name="codeId"
control={control}
rules={{ required: true, maxLength: 20 }}
render={({ field }) => (
<TextField
fullWidth
label={t('code.code_id')}
name="codeId"
required
variant="outlined"
disabled={disabled}
margin="dense"
{...field}
/>
)}
/>
{errors.codeId && (
<ValidationAlert
fieldError={errors.codeId}
target={[20]}
label={t('code.code_id')}
/>
)}
<Controller
name="codeName"
control={control}
rules={{ required: true, maxLength: 500 }}
render={({ field }) => (
<TextField
fullWidth
label={t('code.code_name')}
name="codeName"
required
variant="outlined"
margin="dense"
{...field}
/>
)}
/>
{errors.codeName && (
<ValidationAlert
fieldError={errors.codeName}
target={[500]}
label={t('code.code_name')}
/>
)}
<Controller
name="codeDescription"
control={control}
rules={{ required: false, maxLength: 500 }}
render={({ field }) => (
<TextField
fullWidth
label={t('code.code_description')}
name="codeDescription"
variant="outlined"
margin="dense"
{...field}
/>
)}
/>
{errors.codeDescription && (
<ValidationAlert
fieldError={errors.codeDescription}
target={[500]}
label={t('code.code_description')}
/>
)}
<Controller
name="sortSeq"
control={control}
rules={{ required: false, maxLength: 3 }}
render={({ field }) => (
<TextField
type="number"
fullWidth
label={t('common.sort_seq')}
name="sortSeq"
variant="outlined"
margin="dense"
{...field}
/>
)}
/>
{errors.sortSeq && (
<ValidationAlert
fieldError={errors.sortSeq}
target={[3]}
label={t('common.sort_seq')}
/>
)}
<FormControlLabel
label={t('common.use_at')}
labelPlacement="start"
control={
<Controller
name="useAt"
control={control}
rules={{ required: false, maxLength: 3 }}
render={({ field: { onChange, ref, value } }) => (
<Switch
inputProps={{ 'aria-label': 'secondary checkbox' }}
onChange={onChange}
inputRef={ref}
checked={value}
/>
)}
/>
}
/>
</CardContent>
</Card>
</Grid>
</Grid>
</FormProvider>
<Grid container spacing={1}>
<Grid item xs={12} sm={6}>
<DetailButtons
handleList={() => {
route.push('/code')
}}
handleSave={handleSubmit(onSubmit)}
/>
</Grid>
</Grid>
</>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
const { id } = query
let data = {}
try {
if (id !== '-1') {
const result = await codeService.getOne(id as string)
if (result) {
data = (await result.data) as CodeSavePayload
}
}
} catch (error) {
console.error(`code item query error ${error.message}`)
if (error.response?.data?.code === 'E003') {
return {
notFound: true,
}
}
}
return {
props: {
id,
initData: data,
},
}
}
export default CodeItem

View File

@@ -0,0 +1,362 @@
import { DetailButtons } from '@components/Buttons'
import ValidationAlert from '@components/EditForm/ValidationAlert'
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 FormControl from '@material-ui/core/FormControl'
import FormControlLabel from '@material-ui/core/FormControlLabel'
import Grid from '@material-ui/core/Grid'
import InputLabel from '@material-ui/core/InputLabel'
import MenuItem from '@material-ui/core/MenuItem'
import Select from '@material-ui/core/Select'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import TextField from '@material-ui/core/TextField'
import { CodeSavePayload, codeService, ICode } from '@service'
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
import { AxiosError } from 'axios'
import { GetServerSideProps } from 'next'
import { useRouter } from 'next/router'
import React from 'react'
import { Controller, FormProvider, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { useSetRecoilState } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
},
content: {
padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`,
},
formControl: {
marginTop: theme.spacing(0.5),
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(0.5),
minWidth: 120,
},
switch: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
},
}),
)
interface ICodeFormInput {
parentCodeId: string
codeId: string
codeName: string
codeDescription: string
sortSeq: number
useAt: boolean
}
export interface ICodeItemsProps {
id: string
parentCodes: ICode[]
initData: CodeSavePayload | null
}
const CodeItem = ({ id, parentCodes, initData }: ICodeItemsProps) => {
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
//상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
//form hook
const methods = useForm<ICodeFormInput>({
defaultValues: {
parentCodeId: initData?.parentCodeId || '',
codeId: initData?.codeId || '',
codeName: initData?.codeName || '',
codeDescription: initData?.codeDescription || '',
sortSeq: initData?.sortSeq || 0,
useAt: typeof initData?.useAt !== 'undefined' ? initData?.useAt : true,
},
})
const {
formState: { errors },
control,
handleSubmit,
} = methods
// 코드ID disabled
const disabled = Object.keys(initData).length > 0
// <목록, 저장> 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
const successCallback = () => {
setSuccessSnackBar('success')
route.back()
}
const errorCallback = (error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
}
//onsubmit 저장
const onSubmit = async (formData: ICodeFormInput) => {
setSuccessSnackBar('loading')
const saved: CodeSavePayload = {
parentCodeId: formData.parentCodeId,
codeId: formData.codeId,
codeName: formData.codeName,
codeDescription: formData.codeDescription,
useAt: formData.useAt,
sortSeq: formData.sortSeq,
}
if (id === '-1') {
codeService.saveDetail({
callback: successCallback,
errorCallback,
data: saved,
})
} else {
codeService.updateDetail({
id,
callback: successCallback,
errorCallback,
data: saved,
})
}
}
return (
<>
<FormProvider {...methods}>
<form>
<Grid container spacing={1}>
<Grid item xs={12} sm={6}>
<Card className={classes.root}>
<CardHeader title={t('code.title')} />
<Divider />
<CardContent className={classes.content}>
<FormControl
variant="outlined"
className={classes.formControl}
>
<InputLabel id="parentCodeId-label" required>
{t('code.code_id')}
</InputLabel>
<Controller
name="parentCodeId"
control={control}
defaultValue={initData?.parentCodeId || ''}
rules={{ required: true }}
render={({ field }) => (
<Select
variant="outlined"
name="parentCodeId"
required
labelId="parentCodeId-label"
label={t('code.code_id')}
margin="dense"
{...field}
disabled={disabled}
>
<MenuItem value="">
<em>{t('code.code_id')}</em>
</MenuItem>
{parentCodes.map(option => (
<MenuItem key={option.codeId} value={option.codeId}>
{option.codeName}
</MenuItem>
))}
</Select>
)}
/>
</FormControl>
<Controller
name="codeId"
control={control}
rules={{ required: true, maxLength: 20 }}
render={({ field }) => (
<TextField
fullWidth
label={t('code.code')}
name="codeId"
required
variant="outlined"
disabled={disabled}
margin="dense"
{...field}
/>
)}
/>
{errors.codeId && (
<ValidationAlert
fieldError={errors.codeId}
target={[20]}
label={t('code.code')}
/>
)}
<Controller
name="codeName"
control={control}
rules={{ required: true, maxLength: 500 }}
render={({ field }) => (
<TextField
fullWidth
label={t('code.code_name')}
name="codeName"
required
variant="outlined"
margin="dense"
{...field}
/>
)}
/>
{errors.codeName && (
<ValidationAlert
fieldError={errors.codeName}
target={[500]}
label={t('code.code_name')}
/>
)}
<Controller
name="codeDescription"
control={control}
rules={{ required: false, maxLength: 500 }}
render={({ field }) => (
<TextField
fullWidth
label={t('code.code_description')}
name="codeDescription"
variant="outlined"
margin="dense"
{...field}
/>
)}
/>
{errors.codeDescription && (
<ValidationAlert
fieldError={errors.codeDescription}
target={[500]}
label={t('code.code_description')}
/>
)}
<Controller
name="sortSeq"
control={control}
rules={{ required: false, maxLength: 3 }}
render={({ field }) => (
<TextField
type="number"
fullWidth
label={t('common.sort_seq')}
name="sortSeq"
variant="outlined"
margin="dense"
{...field}
/>
)}
/>
{errors.sortSeq && (
<ValidationAlert
fieldError={errors.sortSeq}
target={[3]}
label={t('common.sort_seq')}
/>
)}
<FormControlLabel
label={t('common.use_at')}
labelPlacement="start"
control={
<Controller
name="useAt"
control={control}
rules={{ required: false, maxLength: 3 }}
render={({ field: { onChange, ref, value } }) => (
<Switch
inputProps={{ 'aria-label': 'secondary checkbox' }}
onChange={onChange}
inputRef={ref}
checked={value}
/>
)}
/>
}
/>
</CardContent>
</Card>
</Grid>
</Grid>
</form>
</FormProvider>
<Grid container spacing={1}>
<Grid item xs={12} sm={6}>
<DetailButtons
handleList={() => {
route.push('/code/detail')
}}
handleSave={handleSubmit(onSubmit)}
/>
</Grid>
</Grid>
</>
)
}
export const getServerSideProps: GetServerSideProps = async ({
req,
res,
query,
}) => {
const { id } = query
let data = {}
let parentCodes = []
try {
// 신규시에는 사용여부 true인 상위공통코드를 가져오고, 수정시에는 현재 상위공통코드 하나만 가져온다
if (id === '-1') {
const codeList = await codeService.getParentCodeList()
if (codeList) {
parentCodes = (await codeList.data) as ICode[]
}
} else {
const parentCode = await codeService.getParentCode(id as string)
if (parentCode) {
parentCodes.push((await parentCode.data) as ICode[])
}
const result = await codeService.getOneDetail(id as string)
if (result) {
data = (await result.data) as CodeSavePayload
}
}
} catch (error) {
console.error(`codes query error ${error.message}`)
if (error.response?.data?.code === 'E003') {
return {
notFound: true,
}
}
}
return {
props: {
id,
parentCodes,
initData: data,
},
}
}
export default CodeItem

View File

@@ -0,0 +1,318 @@
import { GridButtons } from '@components/Buttons'
import Search from '@components/Search'
// 내부 컴포넌트 및 custom hook, etc...
import CustomDataGrid from '@components/Table/CustomDataGrid'
import { GRID_PAGE_SIZE } from '@constants'
import usePage from '@hooks/usePage'
import useSearchTypes from '@hooks/useSearchType'
import Box from '@material-ui/core/Box'
import MenuItem from '@material-ui/core/MenuItem'
// material-ui deps
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import TextField from '@material-ui/core/TextField'
import {
GridCellParams,
GridColDef,
GridValueGetterParams,
} from '@material-ui/data-grid'
//api
import { codeService, ICode } from '@service'
import { conditionAtom, conditionValue, errorStateSelector } from '@stores'
import { Page, rownum } from '@utils'
import { AxiosError } from 'axios'
import { GetServerSideProps, NextPage } from 'next'
import { TFunction } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
// 상태관리 recoil
import { useRecoilState, useSetRecoilState } from 'recoil'
// material-ui style
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
search: {
padding: theme.spacing(1),
textAlign: 'center',
width: '20vw',
minWidth: 80,
maxWidth: 200,
},
}),
)
//그리드 컬럼 정의
type ColumnsType = (
data: Page,
deleteCode: (id: string) => void,
updateCode: (id: string) => void,
toggleIsUse: (event: React.ChangeEvent<HTMLInputElement>, id: string) => void,
t?: TFunction,
) => GridColDef[]
const getColumns: ColumnsType = (
data,
deleteCode,
updateCode,
toggleIsUse,
t,
) => {
return [
{
field: 'rownum',
headerName: t('common.no'), // 번호
headerAlign: 'center',
align: 'center',
sortable: false,
valueGetter: (params: GridValueGetterParams) =>
rownum(data, params.api.getRowIndex(params.id), 'desc'),
},
{
field: 'parentCodeId',
headerName: t('code.code_id'), // 코드ID
headerAlign: 'center',
width: 200,
sortable: false,
},
{
field: 'codeId',
headerName: t('code.code'), // 코드
headerAlign: 'center',
width: 200,
sortable: false,
},
{
field: 'codeName',
headerName: t('code.code_name'), // 코드명
headerAlign: 'center',
width: 300,
sortable: false,
},
{
field: 'useAt',
headerName: t('common.use_at'), // 사용여부
headerAlign: 'center',
align: 'center',
width: 120,
sortable: false,
renderCell: (params: GridCellParams) => (
<Switch
checked={Boolean(params.value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
toggleIsUse(event, params.row.codeId)
}
/>
),
},
{
field: 'id',
headerName: t('common.manage'), // 관리
headerAlign: 'center',
align: 'center',
width: 200,
sortable: false,
renderCell: (params: GridCellParams) => (
<GridButtons
id={params.row.codeId as string}
handleUpdate={updateCode}
handleDelete={deleteCode}
/>
),
},
]
}
interface IParentCodeProps {
parentCodes: ICode[]
}
const conditionKey = 'code-detail'
// 실제 render되는 컴포넌트
const CodeDetail: NextPage<IParentCodeProps> = ({ parentCodes }) => {
// props 및 전역변수
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
//조회조건 select items
const searchTypes = useSearchTypes([
{
key: 'codeId',
label: t('code.code_id'),
},
{
key: 'codeName',
label: t('code.code_name'),
},
])
/**
* 상태관리 필요한 훅
*/
//조회조건 상태관리
const [keywordState, setKeywordState] = useRecoilState(
conditionAtom(conditionKey),
)
const setErrorState = useSetRecoilState(errorStateSelector)
// 공통코드 관리 기능에서 넘어오는 경우 parameter
const queryParentCodeId = route.query.parentCodeId as string
//현 페이지내 필요한 hook
const { page, setPageValue } = usePage(conditionKey)
const [customKeyword, setCustomKeyword] = useState<conditionValue>({
parentCodeId: keywordState?.parentCodeId || queryParentCodeId || '-',
})
//목록 데이터 조회 및 관리
const { data, mutate } = codeService.searchDetail({
parentCodeId: keywordState?.parentCodeId || queryParentCodeId || '',
keywordType: keywordState?.keywordType || 'codeId',
keyword: keywordState?.keyword || '',
size: GRID_PAGE_SIZE,
page,
})
/**
* 비지니스 로직
*/
//에러 callback
const errorCallback = useCallback((error: AxiosError) => {
setErrorState({
error,
})
}, [])
//삭제
const deleteCode = useCallback((id: string) => {
codeService.deleteDetail({
callback: mutate,
errorCallback,
id,
})
}, [])
//수정 시 상세 화면 이동
const updateCode = useCallback((id: string) => {
route.push(`/code/detail/${id}`)
}, [])
//사용여부 toggle 시 바로 update
const toggleIsUse = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>, id: string) => {
codeService.updateUseDetail({
callback: mutate,
errorCallback,
id,
useAt: event.target.checked,
})
},
[page, customKeyword],
)
// 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const columns = useMemo(() => {
return getColumns(data, deleteCode, updateCode, toggleIsUse, t)
}, [data])
//목록 조회
const handleSearch = () => {
if (page === 0) {
mutate(data, false)
} else {
setPageValue(0)
}
}
// 조회조건 select onchange
const handleParentCodeIdChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
event.preventDefault()
setCustomKeyword({
parentCodeId: event.target.value,
})
}
//datagrid page change event
const handlePageChange = (page: number, details?: any) => {
setPageValue(page)
}
return (
<div className={classes.root}>
<Search
keywordTypeItems={searchTypes}
handleSearch={handleSearch}
handleRegister={() => {
route.push('/code/detail/-1')
}}
conditionKey={conditionKey}
isNotWrapper={true}
customKeyword={customKeyword}
conditionNodes={
<Box className={classes.search}>
<TextField
id="select-parentCodeId"
select
value={customKeyword.parentCodeId}
onChange={handleParentCodeIdChange}
variant="outlined"
fullWidth
>
<MenuItem key="-" value="-">
<em>{t('code.code_id')}</em>
</MenuItem>
{parentCodes.map(option => (
<MenuItem key={option.codeId} value={option.codeId}>
{option.codeName}
</MenuItem>
))}
</TextField>
</Box>
}
/>
<CustomDataGrid
classes={classes}
rows={data?.content}
columns={columns}
rowCount={data?.totalElements}
paginationMode="server"
pageSize={GRID_PAGE_SIZE}
page={page}
onPageChange={handlePageChange}
getRowId={r => r.codeId}
/>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async context => {
let parentCodes = []
try {
const codeList = await codeService.getParentCodeList()
if (codeList) {
parentCodes = (await codeList.data) as ICode[]
}
} catch (error) {
console.error(`codes query error ${error.message}`)
}
return {
props: {
parentCodes,
},
}
}
export default CodeDetail

View File

@@ -0,0 +1,281 @@
import { GridButtons } from '@components/Buttons'
import Search from '@components/Search'
// 내부 컴포넌트 및 custom hook, etc...
import CustomDataGrid from '@components/Table/CustomDataGrid'
import { GRID_PAGE_SIZE } from '@constants'
import usePage from '@hooks/usePage'
import useSearchTypes from '@hooks/useSearchType'
import Box from '@material-ui/core/Box'
import Button from '@material-ui/core/Button'
// material-ui deps
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import {
GridCellParams,
GridColDef,
GridValueGetterParams,
} from '@material-ui/data-grid'
//api
import { codeService } from '@service'
import { conditionAtom, errorStateSelector } from '@stores'
import { Page, rownum } from '@utils'
import { AxiosError } from 'axios'
import { NextPage } from 'next'
import { TFunction, useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useCallback, useMemo } from 'react'
// 상태관리 recoil
import { useRecoilValue, useSetRecoilState } from 'recoil'
// material-ui style
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
}),
)
//그리드 컬럼 정의
type ColumnsType = (
data: Page,
routeCodeDetail: (id: string) => void,
deleteCode: (id: string) => void,
updateCode: (id: string) => void,
toggleIsUse: (event: React.ChangeEvent<HTMLInputElement>, id: string) => void,
t?: TFunction,
) => GridColDef[]
const getColumns: ColumnsType = (
data,
routeCodeDetail,
deleteCode,
updateCode,
toggleIsUse,
t,
) => {
return [
{
field: 'rownum',
headerName: t('common.no'), // 번호
headerAlign: 'center',
align: 'center',
sortable: false,
valueGetter: (params: GridValueGetterParams) =>
rownum(data, params.api.getRowIndex(params.id), 'desc'),
},
{
field: 'codeId',
headerName: t('code.code_id'), // 코드ID
headerAlign: 'center',
width: 150,
sortable: false,
},
{
field: 'codeName',
headerName: t('code.code_name'), // 코드명
headerAlign: 'center',
width: 200,
sortable: false,
},
{
field: 'useAt',
headerName: t('common.use_at'), // 사용여부
headerAlign: 'center',
align: 'center',
width: 120,
sortable: false,
renderCell: (params: GridCellParams) => (
<Switch
checked={Boolean(params.value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
toggleIsUse(event, params.row.codeId)
}
/>
),
},
{
field: 'codeDetailCount',
headerName: t('code.detail_count'), // 코드상세수
headerAlign: 'center',
align: 'center',
width: 120,
sortable: false,
},
{
field: 'id',
headerName: t('common.manage'), // 관리
headerAlign: 'center',
align: 'center',
width: 300,
sortable: false,
renderCell: (params: GridCellParams) => (
<>
<Box mr={1}>
<Button
variant="outlined"
color="default"
size="small"
onClick={() => routeCodeDetail(params.row.codeId)}
>
{t('code.detail.list')}
</Button>
</Box>
<GridButtons
id={params.row.codeId as string}
handleUpdate={updateCode}
handleDelete={deleteCode}
/>
</>
),
},
]
}
const conditionKey = 'code'
// 실제 render되는 컴포넌트
const Code: NextPage = () => {
// props 및 전역변수
// const { id } = props
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
//조회조건 select items
const searchTypes = useSearchTypes([
{
key: 'codeId',
label: t('code.code_id'),
},
{
key: 'codeName',
label: t('code.code_name'),
},
])
/**
* 상태관리 필요한 훅
*/
//조회조건 상태관리
const keywordState = useRecoilValue(conditionAtom(conditionKey))
const setErrorState = useSetRecoilState(errorStateSelector)
//현 페이지내 필요한 hook
const { page, setPageValue } = usePage(conditionKey)
//목록 데이터 조회 및 관리
const { data, mutate } = codeService.search({
keywordType: keywordState?.keywordType || 'codeId',
keyword: keywordState?.keyword || '',
size: GRID_PAGE_SIZE,
page,
})
/**
* 비지니스 로직
*/
//에러 callback
const errorCallback = useCallback((error: AxiosError) => {
setErrorState({
error,
})
}, [])
// 코드상세목록
const routeCodeDetail = useCallback((id: string) => {
route.push(
{
pathname: '/code/detail',
query: {
parentCodeId: id,
},
},
'/code/detail',
)
}, [])
//삭제
const deleteCode = useCallback((id: string) => {
codeService.delete({
callback: mutate,
errorCallback,
id,
})
}, [])
//수정 시 상세 화면 이동
const updateCode = useCallback((id: string) => {
route.push(`/code/${id}`)
}, [])
//사용여부 toggle 시 바로 update
const toggleIsUse = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>, id: string) => {
codeService.updateUse({
callback: mutate,
errorCallback,
id,
useAt: event.target.checked,
})
},
[page],
)
// 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const columns = useMemo(() => {
return getColumns(
data,
routeCodeDetail,
deleteCode,
updateCode,
toggleIsUse,
t,
)
}, [data])
//목록 조회
const handleSearch = () => {
if (page === 0) {
mutate(data, false)
} else {
setPageValue(0)
}
}
//datagrid page change event
const handlePageChange = (page: number, details?: any) => {
setPageValue(page)
}
return (
<div className={classes.root}>
<Search
keywordTypeItems={searchTypes}
handleSearch={handleSearch}
handleRegister={() => {
route.push('code/-1')
}}
conditionKey={conditionKey}
/>
<CustomDataGrid
classes={classes}
rows={data?.content}
columns={columns}
rowCount={data?.totalElements}
paginationMode="server"
pageSize={GRID_PAGE_SIZE}
page={page}
onPageChange={handlePageChange}
getRowId={r => r.codeId}
/>
</div>
)
}
export default Code

View File

@@ -0,0 +1,265 @@
import { DetailButtons } from '@components/Buttons'
import CustomAlert from '@components/CustomAlert'
import ValidationAlert from '@components/EditForm/ValidationAlert'
import Editor from '@components/Editor'
import Box from '@material-ui/core/Box'
import Grid from '@material-ui/core/Grid'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import TextField from '@material-ui/core/TextField'
import { ContentSavePayload, contentService } from '@service'
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
import { format } from '@utils'
import { AxiosError } from 'axios'
import { GetServerSideProps } from 'next'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useState } from 'react'
import { Controller, FormProvider, useForm } from 'react-hook-form'
import { useSetRecoilState } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginTop: theme.spacing(1),
'& .MuiOutlinedInput-input': {
padding: theme.spacing(2),
},
},
switch: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
},
buttonContainer: {
display: 'flex',
margin: theme.spacing(1),
justifyContent: 'center',
'& .MuiButton-root': {
margin: theme.spacing(1),
},
},
backdrop: {
zIndex: theme.zIndex.drawer + 1,
color: '#fff',
},
}),
)
interface IContentFormInput {
contentName: string
contentRemark: string
contentValue: string
}
export interface IContentItemsProps {
contentNo: string
initData: ContentSavePayload | null
}
const ContentItem = ({ contentNo, initData }: IContentItemsProps) => {
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
// 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
// 상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
// alert
const [customAlert, setCustomAlert] = useState<any>({
open: false,
message: '',
handleAlert: () => setCustomAlert({ open: false }),
})
// Editor
const [contentValue, setContentValue] = useState<string>(
initData?.contentValue || '',
)
// form hook
const methods = useForm<IContentFormInput>({
defaultValues: {
contentName: initData?.contentName || '',
contentRemark: initData?.contentRemark || '',
},
})
const {
formState: { errors },
control,
handleSubmit,
} = methods
const successCallback = () => {
setSuccessSnackBar('success')
route.back()
}
const errorCallback = (error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
}
// handleSubmit 저장
const handleSave = async (formData: IContentFormInput) => {
setSuccessSnackBar('loading')
const saved: ContentSavePayload = {
contentName: formData.contentName,
contentRemark: formData.contentRemark,
contentValue,
}
if (!contentValue) {
setCustomAlert({
open: true,
message: format(t('valid.required.format'), [
t('content.content_value'),
]),
handleAlert: () => {
setCustomAlert({ open: false })
},
})
return
}
if (contentNo === '-1') {
await contentService.save({
callback: successCallback,
errorCallback,
data: saved,
})
} else {
await contentService.update({
contentNo,
callback: successCallback,
errorCallback,
data: saved,
})
}
}
return (
<div className={classes.root}>
<FormProvider {...methods}>
<form>
<Grid container spacing={1}>
<Grid item xs={12} sm={12}>
<Box boxShadow={1}>
<Controller
name="contentName"
control={control}
rules={{ required: true, maxLength: 100 }}
render={({ field }) => (
<TextField
autoFocus
label={t('content.content_name')}
name="contentName"
required
inputProps={{ maxLength: 100 }}
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('content.content_name'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.contentName && (
<ValidationAlert
fieldError={errors.contentName}
target={[100]}
label={t('content.content_name')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={12}>
<Box boxShadow={1}>
<Controller
name="contentRemark"
control={control}
rules={{ required: true, maxLength: 200 }}
render={({ field }) => (
<TextField
label={t('content.content_remark')}
name="contentRemark"
inputProps={{ maxLength: 200 }}
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('content.content_remark'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.contentRemark && (
<ValidationAlert
fieldError={errors.contentRemark}
target={[200]}
label={t('content.content_remark')}
/>
)}
</Box>
</Grid>
</Grid>
<Editor contents={contentValue} setContents={setContentValue} />
</form>
</FormProvider>
<DetailButtons
handleList={() => {
route.back()
}}
handleSave={handleSubmit(handleSave)}
/>
<CustomAlert
contentText={customAlert.message}
open={customAlert.open}
handleAlert={() => setCustomAlert({ open: false })}
/>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
const contentNo = query.id as string
let data = {}
try {
if (contentNo !== '-1') {
const result = await contentService.get(contentNo)
if (result) {
data = (await result.data) as ContentSavePayload
}
}
} catch (error) {
console.error(`content item query error ${error.message}`)
if (error.response?.data?.code === 'E003') {
return {
notFound: true,
}
}
}
return {
props: {
contentNo,
initData: data,
},
}
}
export default ContentItem

View File

@@ -0,0 +1,242 @@
import { GridButtons } from '@components/Buttons'
import { PopupProps } from '@components/DialogPopup'
import Search, { IKeywordType } from '@components/Search'
import CustomDataGrid from '@components/Table/CustomDataGrid'
import { GRID_PAGE_SIZE } from '@constants'
import usePage from '@hooks/usePage'
// 내부 컴포넌트 및 custom hook, etc...
import { convertStringToDateFormat } from '@libs/date'
import Button from '@material-ui/core/Button'
// material-ui deps
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import {
GridCellParams,
GridColDef,
GridValueFormatterParams,
GridValueGetterParams,
} from '@material-ui/data-grid'
// api
import { contentService } from '@service'
import { conditionAtom, errorStateSelector } from '@stores'
import { Page, rownum } from '@utils'
import { AxiosError } from 'axios'
import { NextPage } from 'next'
import { TFunction, useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useCallback, useMemo } from 'react'
// 상태관리 recoil
import { useRecoilValue, useSetRecoilState } from 'recoil'
// material-ui style
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
}),
)
// 그리드 컬럼 정의
type ColumnsType = (
data: Page,
deleteContent: (contentNo: string) => void,
updateContent: (contentNo: string) => void,
t?: TFunction,
handlePopup?: (row: any) => void,
) => GridColDef[]
const getColumns: ColumnsType = (
data,
deleteContent,
updateContent,
t,
handlePopup,
) => [
{
field: 'rownum',
headerName: t('common.no'),
headerAlign: 'center',
align: 'center',
width: 80,
sortable: false,
valueGetter: (params: GridValueGetterParams) =>
rownum(data, params.api.getRowIndex(params.id), 'asc'),
},
{
field: 'contentName',
headerName: t('content.content_name'),
headerAlign: 'center',
align: 'left',
flex: 1,
sortable: false,
},
{
field: 'lastModifiedBy',
headerName: t('common.last_modified_by'),
headerAlign: 'center',
align: 'center',
width: 150,
sortable: false,
},
{
field: 'modifiedDate',
headerName: t('common.modified_date'),
headerAlign: 'center',
align: 'center',
width: 200,
sortable: false,
valueFormatter: (params: GridValueFormatterParams) =>
convertStringToDateFormat(params.value as string, 'yyyy-MM-dd HH:mm:ss'),
},
{
field: 'buttons',
headerName: handlePopup ? t('common.select') : t('common.manage'),
headerAlign: 'center',
align: 'center',
width: 150,
sortable: false,
renderCell: function renderCellButtons(params: GridCellParams) {
return handlePopup ? (
<Button
onClick={() => {
handlePopup(params.row)
}}
variant="outlined"
color="inherit"
size="small"
>
{t('common.select')}
</Button>
) : (
<GridButtons
id={params.row.contentNo as string}
handleDelete={deleteContent}
handleUpdate={updateContent}
/>
)
},
},
]
const conditionKey = 'content'
export interface ContentProps extends PopupProps {}
// 실제 render되는 컴포넌트
const Content: NextPage<ContentProps> = props => {
// props 및 전역변수
const { handlePopup } = props
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
// 조회조건 select items
const searchTypes: IKeywordType[] = [
{
key: 'contentName',
label: t('content.content_name'),
},
]
/**
* 상태관리 필요한 훅
*/
// 조회조건 상태관리
const keywordState = useRecoilValue(conditionAtom(conditionKey))
const setErrorState = useSetRecoilState(errorStateSelector)
// 현 페이지내 필요한 hook
const { page, setPageValue } = usePage(conditionKey)
// 목록 데이터 조회 및 관리
const { data, mutate } = contentService.search({
keywordType: keywordState?.keywordType || 'contentName',
keyword: keywordState?.keyword || '',
size: GRID_PAGE_SIZE,
page,
})
/**
* 비지니스 로직
*/
// 에러 callback
const errorCallback = useCallback(
(error: AxiosError) => {
setErrorState({
error,
})
},
[setErrorState],
)
// 삭제
const deleteContent = useCallback(
(contentNo: string) => {
contentService.delete({
contentNo,
callback: mutate,
errorCallback,
})
},
[errorCallback, mutate],
)
// 수정 시 상세 화면 이동
const updateContent = useCallback(
(contentNo: string) => {
route.push(`/content/${contentNo}`)
},
[route],
)
// 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const columns = useMemo(
() => getColumns(data, deleteContent, updateContent, t, handlePopup),
[data, deleteContent, updateContent, t, handlePopup],
)
// 목록 조회
const handleSearch = () => {
if (page === 0) {
mutate()
} else {
setPageValue(0)
}
}
// datagrid page change event
const handlePageChange = (page: number, details?: any) => {
setPageValue(page)
}
const handleRegister = () => {
route.push('content/-1')
}
return (
<div className={classes.root}>
<Search
keywordTypeItems={searchTypes}
handleSearch={handleSearch}
handleRegister={handlePopup ? undefined : handleRegister}
conditionKey={conditionKey}
/>
<CustomDataGrid
page={page}
classes={classes}
rows={data?.content}
columns={columns}
rowCount={data?.totalElements}
paginationMode="server"
pageSize={GRID_PAGE_SIZE}
onPageChange={handlePageChange}
getRowId={r => r.contentNo}
/>
</div>
)
}
export default Content

View File

@@ -0,0 +1,25 @@
import Loader from '@components/Loader'
import { GetServerSideProps } from 'next'
import React from 'react'
/**
* 통계 페이지로 redirect
*/
const Home = () => {
return (
<>
<Loader />
</>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
return {
redirect: {
permanent: true,
destination: '/statistics',
},
}
}
export default Home

View File

@@ -0,0 +1,234 @@
import { DetailButtons } from '@components/Buttons'
import ValidationAlert from '@components/EditForm/ValidationAlert'
import { Card, CardActions, CardContent } from '@material-ui/core'
import Box from '@material-ui/core/Box'
import CardHeader from '@material-ui/core/CardHeader'
import Divider from '@material-ui/core/Divider'
import FormControlLabel from '@material-ui/core/FormControlLabel'
import Grid from '@material-ui/core/Grid'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import TextField from '@material-ui/core/TextField'
import { ILocation, locationService } from '@service'
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
import { GetServerSideProps } from 'next'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React from 'react'
import { Controller, FormProvider, useForm } from 'react-hook-form'
import { useSetRecoilState } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginTop: theme.spacing(1),
'& .MuiOutlinedInput-input': {
padding: theme.spacing(2),
},
},
card: {
width: '100%',
},
cardActions: {
justifyContent: 'center',
},
switch: {
width: '100%',
justifyContent: 'start',
border: '1px solid rgba(0, 0, 0, 0.23)',
borderRadius: theme.spacing(0.5),
padding: theme.spacing(1),
marginTop: theme.spacing(1),
},
}),
)
interface LocationDetailProps {
locationId: string
initData?: ILocation
}
const LocationDetail = ({ locationId, initData }: LocationDetailProps) => {
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
//form hook
const methods = useForm<ILocation>({
defaultValues: initData,
})
const {
formState: { errors },
control,
handleSubmit,
} = methods
//상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
// <목록, 저장> 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
const handleSave = async (formData: ILocation) => {
setSuccessSnackBar('loading')
try {
let result
if (locationId === '-1') {
result = await locationService.save(formData)
} else {
result = await locationService.update(parseInt(locationId), formData)
}
if (result) {
setSuccessSnackBar('success')
route.back()
}
} catch (error) {
setSuccessSnackBar('none')
setErrorState({ error })
}
}
const handleList = () => {
route.back()
}
return (
<div className={classes.root}>
<FormProvider {...methods}>
<form>
<Grid container spacing={1}>
<Card className={classes.card}>
<CardHeader title={t('location')} />
<Divider />
<CardContent>
<Controller
name="locationName"
control={control}
rules={{ required: true, maxLength: 200 }}
render={({ field }) => (
<TextField
fullWidth
label={t('location.name')}
name="locationName"
required
variant="outlined"
margin="dense"
{...field}
/>
)}
defaultValue={''}
/>
{errors.locationName && (
<ValidationAlert
fieldError={errors.locationName}
target={[200]}
label={t('location.name')}
/>
)}
<Controller
name="sortSeq"
control={control}
rules={{
required: true,
maxLength: 3,
pattern: {
value: /^[0-9]*$/,
message: t('valid.valueAsNumber'),
},
}}
render={({ field }) => (
<TextField
fullWidth
label={t('common.sort_seq')}
name="sortSeq"
required
variant="outlined"
margin="dense"
{...field}
/>
)}
defaultValue={null}
/>
{errors.sortSeq && (
<ValidationAlert
fieldError={errors.sortSeq}
target={[3]}
label={t('common.sort_seq')}
/>
)}
<Box className={classes.switch}>
<FormControlLabel
label={t('common.use_at')}
labelPlacement="start"
control={
<Controller
name="isUse"
control={control}
rules={{ required: false, maxLength: 3 }}
render={({ field: { onChange, ref, value } }) => (
<Switch
inputProps={{ 'aria-label': 'secondary checkbox' }}
onChange={onChange}
inputRef={ref}
checked={value}
/>
)}
/>
}
/>
</Box>
</CardContent>
<Divider />
<CardActions className={classes.cardActions}>
<DetailButtons
handleSave={handleSubmit(handleSave)}
handleList={handleList}
/>
</CardActions>
</Card>
</Grid>
</form>
</FormProvider>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
const locationId = query.id as string
if (locationId === '-1') {
return {
props: {
locationId,
},
}
}
let data = {}
try {
const result = await locationService.get(parseInt(locationId))
if (result) {
data = (await result.data) as ILocation
}
} catch (error) {
console.error(`content item query error ${error.message}`)
if (error.response?.data?.code === 'E003') {
return {
notFound: true,
}
}
}
return {
props: {
locationId,
initData: data,
},
}
}
export default LocationDetail

View File

@@ -0,0 +1,221 @@
import { GridButtons } from '@components/Buttons'
import Search from '@components/Search'
import CustomDataGrid from '@components/Table/CustomDataGrid'
import { GRID_PAGE_SIZE } from '@constants'
import usePage from '@hooks/usePage'
import useSearchTypes from '@hooks/useSearchType'
import { convertStringToDateFormat } from '@libs/date'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import {
GridCellParams,
GridColDef,
GridValueFormatterParams,
GridValueGetterParams,
} from '@material-ui/data-grid'
import { locationService } from '@service'
import { conditionAtom, errorStateSelector } from '@stores'
import { Page, rownum } from '@utils'
import { useRouter } from 'next/router'
import React, { useCallback, useMemo } from 'react'
import { TFunction, useTranslation } from 'react-i18next'
import { useRecoilValue, useSetRecoilState } from 'recoil'
// material-ui style
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
}),
)
const conditionKey = 'location'
type ColumnType = (
data: Page,
handleDelete: (id: number) => void,
handleUpdate: (id: number) => void,
toggleIsUse: (event: React.ChangeEvent<HTMLInputElement>, id: number) => void,
t: TFunction,
) => GridColDef[]
//그리드 컬럼 정의
const getColumns: ColumnType = (
data: Page,
handleDelete: (id: number) => void,
handleUpdate: (id: number) => void,
toggleIsUse: (event: React.ChangeEvent<HTMLInputElement>, id: number) => void,
t,
) => {
return [
{
field: 'rownum',
headerName: t('common.no'),
headerAlign: 'center',
align: 'center',
width: 80,
sortable: false,
valueGetter: (params: GridValueGetterParams) =>
rownum(data, params.api.getRowIndex(params.id), 'asc'),
},
{
field: 'locationName',
headerName: t('location.name'),
headerAlign: 'center',
align: 'left',
flex: 1,
sortable: false,
},
{
field: 'isUse',
headerName: t('common.use_at'),
headerAlign: 'center',
align: 'center',
width: 120,
sortable: false,
renderCell: (params: GridCellParams) => (
<Switch
checked={Boolean(params.value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
toggleIsUse(event, params.row.locationId)
}
/>
),
},
{
field: 'createDate',
headerName: t('common.created_datetime'),
headerAlign: 'center',
align: 'center',
width: 200,
sortable: false,
valueFormatter: (params: GridValueFormatterParams) =>
convertStringToDateFormat(
params.value as string,
'yyyy-MM-dd HH:mm:ss',
),
},
{
field: 'locationId',
headerName: t('common.manage'),
headerAlign: 'center',
align: 'center',
width: 200,
sortable: false,
renderCell: (params: GridCellParams) => (
<GridButtons
id={params.value as string}
handleDelete={handleDelete}
handleUpdate={handleUpdate}
/>
),
},
]
}
const Location = () => {
const classes = useStyles()
const { t } = useTranslation()
const router = useRouter()
//조회조건 상태관리
const keywordState = useRecoilValue(conditionAtom(conditionKey))
// 에러 상태관리
const setErrorState = useSetRecoilState(errorStateSelector)
// pagination 상태관리
const { page, setPageValue } = usePage(conditionKey)
//조회조건 select items
const searchTypes = useSearchTypes([
{
key: 'locationName',
label: t('location.name'),
},
])
//목록 데이터 조회 및 관리
const { data, mutate } = locationService.search({
keywordType: keywordState?.keywordType || 'locationName',
keyword: keywordState?.keyword || '',
size: GRID_PAGE_SIZE,
page,
})
//목록 조회
const handleSearch = () => {
if (page === 0) {
mutate()
} else {
setPageValue(0)
}
}
const handleRegister = () => {
router.push('location/-1')
}
//datagrid page change event
const handlePageChange = (page: number, details?: any) => {
setPageValue(page)
}
const handleDelete = async (id: number) => {
try {
const result = await locationService.delete(id)
if (result?.status === 204) {
mutate()
}
} catch (error) {
setErrorState({ error })
}
}
const handleUpdate = (id: number) => {
router.push(`location/${id}`)
}
//사용여부 toggle 시 바로 update
const toggleIsUse = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>, id: number) => {
try {
const result = await locationService.updateUse(id, event.target.checked)
if (result?.status === 204) {
mutate()
}
} catch (error) {
setErrorState({ error })
}
},
[page],
)
// 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const columns = useMemo(() => {
return getColumns(data, handleDelete, handleUpdate, toggleIsUse, t)
}, [data])
return (
<div className={classes.root}>
<Search
keywordTypeItems={searchTypes}
handleSearch={handleSearch}
handleRegister={handleRegister}
conditionKey={conditionKey}
/>
<CustomDataGrid
classes={classes}
rows={data?.content}
columns={columns}
rowCount={data?.totalElements}
paginationMode="server"
pageSize={GRID_PAGE_SIZE}
page={page}
onPageChange={handlePageChange}
getRowId={r => r.locationId}
/>
</div>
)
}
export default Location

View File

@@ -0,0 +1,412 @@
import { ConfirmDialog } from '@components/Confirm'
import CustomAlert from '@components/CustomAlert'
import DraggableTreeMenu from '@components/DraggableTreeMenu'
import TreeSubButtons from '@components/DraggableTreeMenu/TreeSubButtons'
import { findTreeItem } from '@components/DraggableTreeMenu/TreeUtils'
import { MenuEditForm } from '@components/EditForm'
import Button from '@material-ui/core/Button'
import ButtonGroup from '@material-ui/core/ButtonGroup'
import Card from '@material-ui/core/Card'
import CardContent from '@material-ui/core/CardContent'
import Grid from '@material-ui/core/Grid'
import MenuItem from '@material-ui/core/MenuItem'
import Paper from '@material-ui/core/Paper'
import Select from '@material-ui/core/Select'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Typography from '@material-ui/core/Typography'
import AddIcon from '@material-ui/icons/Add'
import DeleteIcon from '@material-ui/icons/Delete'
import SettingsIcon from '@material-ui/icons/Settings'
import {
codeService,
ICode,
IMenuInfoForm,
IMenuSavePayload,
IMenuTree,
ISite,
menuService,
} from '@service'
import {
conditionAtom,
detailButtonsSnackAtom,
draggableTreeExpandedAtom,
draggableTreeSelectedAtom,
errorStateSelector,
treeChangeNameAtom,
} from '@stores'
import produce from 'immer'
import { GetServerSideProps } from 'next'
import { useSnackbar } from 'notistack'
import React, { createContext, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
},
paper: {
padding: theme.spacing(2),
background: theme.palette.background.default,
},
buttons: {
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
},
info: {
minHeight: 350,
background: theme.palette.background.paper,
},
}),
)
export interface MenuProps {
sites: ISite[]
menuTypes?: ICode[]
}
const conditionKey = 'menu'
const defaultMenu: IMenuSavePayload = {
name: 'newMenu',
parentId: null,
siteId: null,
sortSeq: 1,
level: 1,
isShow: true,
isUse: true,
}
interface ICustomAlertState {
open: boolean
message: string
}
export const MenuFormContext = createContext<{
menuFormData: IMenuInfoForm
setMenuFormDataHandler: (data: IMenuInfoForm) => void
}>({
menuFormData: undefined,
setMenuFormDataHandler: () => {},
})
const Menu = ({ sites, menuTypes }: MenuProps) => {
const classes = useStyles()
const { t } = useTranslation()
const { enqueueSnackbar } = useSnackbar()
const keywordState = useRecoilValue(conditionAtom(conditionKey))
const setErrorState = useSetRecoilState(errorStateSelector)
const [menuFormData, setMenuFormData] = useState<IMenuInfoForm>(undefined)
const setMenuFormDataHandler = (data: IMenuInfoForm) => {
setMenuFormData(data)
}
const [siteState, setSiteState] = useState<number>(
+keywordState?.siteId || sites[0]?.id,
)
const setExpanded = useSetRecoilState(draggableTreeExpandedAtom)
const [treeSelected, setTreeSelected] = useRecoilState(
draggableTreeSelectedAtom,
)
const [treeChangeName, setTreeChangeName] = useRecoilState(treeChangeNameAtom)
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
const [customAlertState, setCustomAlertState] = useState<ICustomAlertState>({
open: false,
message: '',
})
const [deleteConfirmState, setDeleteConfirmState] =
useState<ICustomAlertState>({
open: false,
message: t('msg.confirm.delete'),
})
const { data, mutate, error } = menuService.getTreeMenus(siteState)
useEffect(() => {
if (treeSelected) {
menuService
.getMenu(treeSelected.menuId)
.then(result => {
setMenuFormDataHandler(result)
})
.catch(error => {
setErrorState({ error })
})
}
}, [treeSelected])
useEffect(() => {
if (treeChangeName.state === 'complete') {
menuService
.updateName(treeChangeName.id, treeChangeName.name)
.then(result => {
setTreeChangeName({
state: 'none',
})
mutate().then(result => {
const selected = findTreeItem(result, treeSelected.menuId, 'menuId')
setTreeSelected(selected.item)
})
})
.catch(error => {
setErrorState({ error })
})
}
}, [treeChangeName])
const handleSiteChange = (event: React.ChangeEvent<{ value: unknown }>) => {
setSiteState(event.target.value as number)
}
const handleSave = async (formData: IMenuInfoForm) => {
console.log(formData)
setSuccessSnackBar('loading')
try {
const result = await menuService.update(treeSelected.menuId, formData)
setSuccessSnackBar('success')
if (result) {
mutate()
}
} catch (error) {
setErrorState({ error })
setSuccessSnackBar('none')
}
}
const handleAddClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
let addMenu: IMenuSavePayload = produce(defaultMenu, draft => {
draft.siteId = siteState
draft.sortSeq = data?.length + 1
draft.name = t('menu.new_menu')
})
if (treeSelected) {
addMenu = produce(addMenu, draft => {
draft.parentId = treeSelected.menuId
draft.level = treeSelected.level + 1
draft.sortSeq =
treeSelected.children.length > 0
? treeSelected.children[treeSelected.children.length - 1].sortSeq +
1
: 1
})
}
try {
const result = await menuService.save(addMenu)
if (result) {
mutate()
}
} catch (error) {
setErrorState({ error })
}
}
const handleDeleteClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
if (!treeSelected) {
setCustomAlertState({
open: true,
message: t('menu.valid.delete'),
})
return
}
setDeleteConfirmState({
...deleteConfirmState,
open: true,
})
}
const handleChangeNameClick = (
event: React.MouseEvent<HTMLButtonElement>,
) => {
event.preventDefault()
if (!treeSelected) {
setCustomAlertState({
open: true,
message: t('menu.valid.change_name'),
})
return
}
setTreeChangeName({
state: 'change',
id: null,
name: null,
})
}
const handleExpand = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
setExpanded('expand')
}
const handleCollapse = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
setExpanded('collapse')
}
const handleDeselect = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
setTreeSelected(undefined)
}
const handleAlert = () => {
setCustomAlertState({
...customAlertState,
open: false,
})
}
const handleConfirmClose = () => {
setDeleteConfirmState({
...deleteConfirmState,
open: false,
})
}
const handleConfirm = async () => {
handleConfirmClose()
try {
await menuService.delete(treeSelected.menuId)
enqueueSnackbar(t('msg.success.delete'), {
variant: 'success',
})
mutate()
setTreeSelected(undefined)
} catch (error) {
setErrorState({ error })
}
}
const handleTreeDnD = async (tree: IMenuTree[]) => {
try {
const result = await menuService.updateDnD(siteState, tree)
mutate()
} catch (error) {
setErrorState({ error })
}
}
return (
<div className={classes.root}>
<Grid container spacing={2}>
<Grid item sm={12} md={4}>
<Paper className={classes.paper}>
<Select fullWidth value={siteState} onChange={handleSiteChange}>
{sites?.map(item => (
<MenuItem key={item.id} value={item.id}>
{item.name}
</MenuItem>
))}
</Select>
<ButtonGroup
className={classes.buttons}
size="small"
aria-label="menu tree buttons"
>
<Button color="primary" onClick={handleAddClick}>
<AddIcon fontSize="small" />
{t('label.button.add')}
</Button>
<Button onClick={handleChangeNameClick}>
<SettingsIcon fontSize="small" />
{t('menu.update_name')}
</Button>
<Button color="secondary" onClick={handleDeleteClick}>
<DeleteIcon fontSize="small" />
{t('label.button.delete')}
</Button>
</ButtonGroup>
{data && (
<DraggableTreeMenu handleTreeDnD={handleTreeDnD} data={data} />
)}
<TreeSubButtons
handleExpand={handleExpand}
handleCollapse={handleCollapse}
handleDeselect={handleDeselect}
/>
</Paper>
<CustomAlert
contentText={customAlertState.message}
open={customAlertState.open}
handleAlert={handleAlert}
/>
<ConfirmDialog
open={deleteConfirmState.open}
contentText={deleteConfirmState.message}
handleClose={handleConfirmClose}
handleConfirm={handleConfirm}
/>
</Grid>
<Grid item sm={12} md={8}>
<MenuFormContext.Provider
value={{ menuFormData, setMenuFormDataHandler }}
>
<Paper className={classes.paper}>
{treeSelected ? (
<MenuEditForm handleSave={handleSave} menuTypes={menuTypes} />
) : (
<Card className={classes.info}>
<CardContent>
<Typography gutterBottom variant="h2">
Tip.
</Typography>
<Typography variant="body2" component="p">
1.
/ .
<br />
2. .
<br />
3. .
</Typography>
</CardContent>
</Card>
)}
</Paper>
</MenuFormContext.Provider>
</Grid>
</Grid>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async context => {
let sites: ISite[] = []
let menuTypes: ICode[] = []
try {
const result = await menuService.getSites()
if (sites) {
sites = result
}
const codeDetails = await codeService.getCodeDetailList('menutype')
if (codeDetails) {
menuTypes = codeDetails.data as ICode[]
}
} catch (error) {
console.error(`menu getServerSideProps error ${error.message}`)
}
return {
props: {
sites,
menuTypes,
},
}
}
export default Menu

View File

@@ -0,0 +1,215 @@
import { DetailButtons } from '@components/Buttons/DetailButtons'
import CustomTreeView, { CustomTreeViewType } from '@components/CustomTreeView'
import TreeSubButtons from '@components/DraggableTreeMenu/TreeSubButtons'
import { HorizontalTabs } from '@components/Tabs'
import Box from '@material-ui/core/Box'
import Button from '@material-ui/core/Button'
import ButtonGroup from '@material-ui/core/ButtonGroup'
import MenuItem from '@material-ui/core/MenuItem'
import Paper from '@material-ui/core/Paper'
import Select from '@material-ui/core/Select'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Tab from '@material-ui/core/Tab'
import CheckBoxIcon from '@material-ui/icons/CheckBox'
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'
import { IRole, ISite, menuService, roleService } from '@service'
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
import { GetServerSideProps } from 'next'
import React, { createRef, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSetRecoilState } from 'recoil'
import { menuRoleService } from 'src/service/MenuRole'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiButtonGroup-contained': {
boxShadow: theme.shadows[0],
},
},
paper: {
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(2),
background: theme.palette.background.paper,
},
select: {
minWidth: 150,
maxWidth: 300,
},
buttons: {
padding: theme.spacing(1, 0.5),
},
buttonGroup: {
'& .MuiButton-containedSizeSmall': {
padding: '4px 6px',
fontSize: '0.8rem',
},
whiteSpace: 'nowrap',
marginRight: theme.spacing(1),
},
}),
)
export interface MenuRoleProps {
sites: ISite[]
roles: IRole[]
}
const MenuRole = (props: MenuRoleProps) => {
const { sites, roles } = props
const classes = useStyles()
const { t } = useTranslation()
const treeViewRef = createRef<CustomTreeViewType>() //treeview Ref
const setErrorState = useSetRecoilState(errorStateSelector)
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
const [tabs, setTabs] = useState<React.ReactNode>(undefined)
const [siteState, setSiteState] = useState<number>(sites ? sites[0].id : null)
const [roleState, setRoleState] = useState<string>(
roles ? roles[0].roleId : '',
)
const [expanded, setExpanded] = useState<boolean>(null)
const { data, mutate, error } = menuRoleService.search(roleState, siteState)
useEffect(() => {
if (roles) {
const createTabs = roles.map(role => {
return (
<Tab
label={role.roleName}
value={role.roleId}
key={`tab-${role.roleId}`}
/>
)
})
setTabs(createTabs)
}
}, [roles])
const handleTab = (roleId: string) => {
setRoleState(roleId)
mutate(data, false)
}
const handleSiteChange = (event: React.ChangeEvent<{ value: unknown }>) => {
setSiteState(event.target.value as number)
}
const handleExpand = () => {
setExpanded(true)
}
const handleCollapse = () => {
setExpanded(false)
}
const handleAllChecked = () => {
treeViewRef.current?.handleAllChecked(true)
}
const handleAllUnchecked = () => {
treeViewRef.current?.handleAllChecked(false)
}
const handleSave = async () => {
setSuccessSnackBar('loading')
if (treeViewRef.current) {
const tree = treeViewRef.current.getTreeData()
try {
const result = await menuRoleService.save(tree)
setSuccessSnackBar('success')
if (result) {
mutate()
}
} catch (error) {
setErrorState({ error })
setSuccessSnackBar('none')
}
}
}
return (
<div className={classes.root}>
{tabs && (
<HorizontalTabs tabs={tabs} init={roleState} handleTab={handleTab} />
)}
<Paper className={classes.paper}>
<Select
className={classes.select}
value={siteState}
onChange={handleSiteChange}
>
{sites?.map(item => (
<MenuItem key={item.id} value={item.id}>
{item.name}
</MenuItem>
))}
</Select>
<Box className={classes.buttons}>
<ButtonGroup
className={classes.buttonGroup}
size="small"
aria-label="menu tree buttons"
variant="contained"
>
<Button onClick={handleAllChecked}>
<CheckBoxIcon fontSize="small" />
{t('label.button.all_checked')}
</Button>
<Button onClick={handleAllUnchecked}>
<CheckBoxOutlineBlankIcon fontSize="small" />
{t('label.button.all_unchecked')}
</Button>
</ButtonGroup>
<TreeSubButtons
handleExpand={handleExpand}
handleCollapse={handleCollapse}
/>
</Box>
{data && (
<CustomTreeView
ref={treeViewRef}
data={data}
isChecked={true}
isAllExpanded={expanded}
/>
)}
<DetailButtons handleSave={handleSave} />
</Paper>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async context => {
let sites: ISite[] = []
let roles: IRole[] = []
try {
const siteResult = await menuService.getSites()
if (siteResult) {
sites = siteResult
}
const roleResult = await roleService.searchAll()
if (roleResult) {
roles = roleResult.data
}
} catch (error) {
console.error(`menu role getServerSideProps error ${error.message}`)
}
return {
props: {
sites,
roles,
},
}
}
export default MenuRole

View File

@@ -0,0 +1,270 @@
import { DetailButtons } from '@components/Buttons'
import ValidationAlert from '@components/EditForm/ValidationAlert'
import Editor from '@components/Editor'
import { getCurrentDate } from '@libs/date'
import Box from '@material-ui/core/Box'
import Grid from '@material-ui/core/Grid'
import MenuItem from '@material-ui/core/MenuItem'
import Paper from '@material-ui/core/Paper'
import Select from '@material-ui/core/Select'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import TextField from '@material-ui/core/TextField'
import { PolicySavePayload, policyService } from '@service'
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
import { AxiosError } from 'axios'
import { GetServerSideProps } from 'next'
import { useRouter } from 'next/router'
import React, { useState } from 'react'
import { Controller, FormProvider, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { useSetRecoilState } from 'recoil'
import { IPolicyType } from '.'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginTop: theme.spacing(1),
'& .MuiOutlinedInput-input': {
padding: theme.spacing(2),
},
},
label: {
padding: theme.spacing(2),
textAlign: 'center',
backgroundColor: theme.palette.background.default,
},
switch: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
},
buttonContainer: {
display: 'flex',
margin: theme.spacing(1),
justifyContent: 'center',
'& .MuiButton-root': {
margin: theme.spacing(1),
},
},
backdrop: {
zIndex: theme.zIndex.drawer + 1,
color: '#fff',
},
}),
)
interface IPolicyFormInput {
policyType: string
isUse: boolean
title: string
contents: string
}
export interface IPolicyItemsProps {
id: string
initData: PolicySavePayload | null
typeList: IPolicyType[]
}
const PolicyItem = ({ id, initData, typeList }: IPolicyItemsProps) => {
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
//상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
//Editor contents
const [policyContents, setPolicyContents] = useState<string>(
initData?.contents || '',
)
//form hook
const methods = useForm<IPolicyFormInput>({
defaultValues: {
policyType: initData?.type || 'TOS',
isUse: typeof initData?.isUse !== 'undefined' ? initData?.isUse : true,
title: initData?.title,
},
})
const {
formState: { errors },
control,
handleSubmit,
} = methods
// <목록, 저장> 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
const successCallback = () => {
setSuccessSnackBar('success')
route.back()
}
const errorCallback = (error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
}
//onsubmit 저장
const onSubmit = async (formData: IPolicyFormInput) => {
setSuccessSnackBar('loading')
const saved: PolicySavePayload = {
title: formData.title,
isUse: formData.isUse,
type: formData.policyType,
regDate: id === '-1' ? getCurrentDate() : initData.regDate,
contents: policyContents,
}
if (id === '-1') {
policyService.save({
callback: successCallback,
errorCallback,
data: saved,
})
} else {
policyService.update({
id,
callback: successCallback,
errorCallback,
data: saved,
})
}
}
return (
<div className={classes.root}>
<FormProvider {...methods}>
<form>
<Grid container spacing={1}>
<Grid item xs={12} sm={2}>
<Paper className={classes.label}>{t('common.type')}</Paper>
</Grid>
<Grid item xs={12} sm={4}>
<Controller
name="policyType"
render={({ field }) => (
<Select variant="outlined" fullWidth {...field}>
{typeList?.map(option => (
<MenuItem key={option.codeId} value={option.codeId}>
{option.codeName}
</MenuItem>
))}
</Select>
)}
control={control}
defaultValue={initData?.type || 'TOS'}
/>
</Grid>
<Grid item xs={12} sm={2}>
<Paper className={classes.label}>{t('common.use_at')}</Paper>
</Grid>
<Grid item xs={12} sm={4}>
<Paper className={classes.switch}>
<Controller
name="isUse"
render={({ field: { onChange, ref, value } }) => (
<Switch
inputProps={{ 'aria-label': 'secondary checkbox' }}
onChange={onChange}
inputRef={ref}
checked={value}
/>
)}
control={control}
/>
</Paper>
</Grid>
<Grid item xs={12} sm={2}>
<Paper className={classes.label}>{t('policy.title')}</Paper>
</Grid>
<Grid item xs={12} sm={10}>
<Box boxShadow={1}>
<Controller
name="title"
render={({ field, fieldState }) => (
<TextField
id="outlined-full-width"
placeholder={`${t('policy.title')} ${t(
'msg.placeholder',
)}`}
fullWidth
variant="outlined"
error={!!fieldState.error}
{...field}
/>
)}
control={control}
rules={{ required: true }}
/>
{errors.title && (
<ValidationAlert
fieldError={errors.title}
label={t('policy.title')}
/>
)}
</Box>
</Grid>
</Grid>
<Editor contents={policyContents} setContents={setPolicyContents} />
</form>
</FormProvider>
<DetailButtons
handleList={() => {
route.push('/policy')
}}
handleSave={handleSubmit(onSubmit)}
/>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async ({
req,
res,
query,
}) => {
const { id } = query
let data = {}
let typeList = []
try {
const typeResult = await policyService.getTypeList()
if (typeResult) {
typeList = (await typeResult.data) as IPolicyType[]
}
if (id !== '-1') {
const result = await policyService.getOne(id as string)
if (result) {
data = (await result.data) as PolicySavePayload
}
}
} catch (error) {
console.error(`policy item query error ${error.message}`)
if (error.response?.data?.code === 'E003') {
return {
notFound: true,
}
}
}
return {
props: {
id,
initData: data,
typeList,
},
}
}
export default PolicyItem

View File

@@ -0,0 +1,294 @@
import { GridButtons } from '@components/Buttons'
import Search from '@components/Search'
import CustomDataGrid from '@components/Table/CustomDataGrid'
import { GRID_PAGE_SIZE } from '@constants'
import usePage from '@hooks/usePage'
import useSearchTypes from '@hooks/useSearchType'
// 내부 컴포넌트 및 custom hook, etc...
import { convertStringToDateFormat } from '@libs/date'
// material-ui deps
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import {
GridCellParams,
GridColDef,
GridValueFormatterParams,
GridValueGetterParams,
} from '@material-ui/data-grid'
//api
import { policyService } from '@service'
import { conditionAtom, errorStateSelector } from '@stores'
import { Page, rownum } from '@utils'
import { AxiosError } from 'axios'
import { GetServerSideProps, NextPage } from 'next'
import { TFunction, useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useCallback, useMemo } from 'react'
// 상태관리 recoil
import { useRecoilValue, useSetRecoilState } from 'recoil'
// material-ui style
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
}),
)
//그리드 컬럼 정의
type ColumnsType = (
data: Page,
typeList: IPolicyType[],
deletePolicy: (id: string) => void,
updatePolicy: (id: string) => void,
toggleIsUse: (event: React.ChangeEvent<HTMLInputElement>, id: string) => void,
t?: TFunction,
) => GridColDef[]
const getColumns: ColumnsType = (
data,
typeList,
deletePolicy,
updatePolicy,
toggleIsUse,
t,
) => {
return [
{
field: 'rownum',
headerName: t('common.no'),
headerAlign: 'center',
align: 'center',
sortable: false,
valueGetter: (params: GridValueGetterParams) =>
rownum(data, params.api.getRowIndex(params.id), 'desc'),
},
{
field: 'type',
headerName: t('common.type'),
headerAlign: 'center',
align: 'center',
width: 150,
sortable: false,
valueGetter: (params: GridValueGetterParams) => {
const type = typeList?.find(item => item.codeId === params.value)
return type?.codeName || ''
},
},
{
field: 'title',
headerName: t('policy.title'),
headerAlign: 'center',
width: 200,
sortable: false,
},
{
field: 'isUse',
headerName: t('common.use_at'),
headerAlign: 'center',
align: 'center',
width: 120,
sortable: false,
renderCell: (params: GridCellParams) => (
<Switch
checked={Boolean(params.value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
toggleIsUse(event, params.row.id)
}
/>
),
},
{
field: 'regDate',
headerName: t('common.created_datetime'),
headerAlign: 'center',
align: 'center',
width: 120,
sortable: false,
valueFormatter: (params: GridValueFormatterParams) => {
return convertStringToDateFormat(
params.value as string,
'yyyy-MM-dd HH:mm:ss',
)
},
},
{
field: 'id',
headerName: t('common.manage'),
headerAlign: 'center',
align: 'center',
width: 200,
sortable: false,
renderCell: (params: GridCellParams) => (
<GridButtons
id={params.value as string}
handleDelete={deletePolicy}
handleUpdate={updatePolicy}
/>
),
},
]
}
const conditionKey = 'policy'
export interface IPolicyType {
codeId: string
codeName: string
sortSeq: number
}
export interface IPolicyProps {
typeList: IPolicyType[]
}
// 실제 render되는 컴포넌트
const Policy: NextPage<IPolicyProps> = ({ typeList }) => {
// props 및 전역변수
// const { id } = props
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
/**
* 상태관리 필요한 훅
*/
//조회조건 상태관리
const keywordState = useRecoilValue(conditionAtom(conditionKey))
const setErrorState = useSetRecoilState(errorStateSelector)
//현 페이지내 필요한 hook
const { page, setPageValue } = usePage(conditionKey)
//목록 데이터 조회 및 관리
const { data, mutate } = policyService.search({
keywordType: keywordState?.keywordType || 'title',
keyword: keywordState?.keyword || '',
size: GRID_PAGE_SIZE,
page,
})
//조회조건 select items
const searchTypes = useSearchTypes([
{
key: 'title',
label: t('policy.title'),
},
{
key: 'contents',
label: t('comment.comment_content'),
},
])
/**
* 비지니스 로직
*/
//에러 callback
const errorCallback = useCallback((error: AxiosError) => {
setErrorState({
error,
})
}, [])
//삭제
const deletePolicy = useCallback((id: string) => {
policyService.delete({
callback: mutate,
errorCallback,
id,
})
}, [])
//수정 시 상세 화면 이동
const updatePolicy = useCallback((id: string) => {
route.push(`/policy/${id}`)
}, [])
//사용여부 toggle 시 바로 update
const toggleIsUse = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>, id: string) => {
policyService.updateUse({
callback: mutate,
errorCallback,
id,
isUse: event.target.checked,
})
},
[page],
)
// 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const columns = useMemo(() => {
return getColumns(
data,
typeList,
deletePolicy,
updatePolicy,
toggleIsUse,
t,
)
}, [data])
//목록 조회
const handleSearch = () => {
if (page === 0) {
mutate()
} else {
setPageValue(0)
}
}
//datagrid page change event
const handlePageChange = (page: number, details?: any) => {
setPageValue(page)
}
return (
<div className={classes.root}>
<Search
keywordTypeItems={searchTypes}
handleSearch={handleSearch}
handleRegister={() => {
route.push('policy/-1')
}}
conditionKey={conditionKey}
/>
<CustomDataGrid
classes={classes}
rows={data?.content}
columns={columns}
rowCount={data?.totalElements}
paginationMode="server"
pageSize={GRID_PAGE_SIZE}
page={page}
onPageChange={handlePageChange}
/>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async context => {
let typeList: IPolicyType[] = []
try {
const result = await policyService.getTypeList()
if (result) {
typeList = result.data
}
} catch (error) {
console.error(`policy list getServerSideProps error ${error.message}`)
}
return {
props: {
typeList,
},
}
}
export default Policy

View File

@@ -0,0 +1,499 @@
import AttachList from '@components/AttachList'
import { CustomButtons, IButtonProps } from '@components/Buttons'
import CustomAlert from '@components/CustomAlert'
import ValidationAlert from '@components/EditForm/ValidationAlert'
import Editor from '@components/Editor'
import { Upload, UploadType } from '@components/Upload'
import Box from '@material-ui/core/Box'
import FormControlLabel from '@material-ui/core/FormControlLabel'
import Grid from '@material-ui/core/Grid'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import TextField from '@material-ui/core/TextField'
import {
BoardSavePayload,
boardService,
fileService,
IAttachmentResponse,
PostsSavePayload,
postsService,
SKINT_TYPE_CODE_FAQ,
SKINT_TYPE_CODE_QNA,
UploadInfoReqeust,
} from '@service'
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
import { format } from '@utils'
import { AxiosError } from 'axios'
import { GetServerSideProps } from 'next'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Controller, FormProvider, useForm } from 'react-hook-form'
import { useSetRecoilState } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginTop: theme.spacing(1),
'& .MuiOutlinedInput-input': {
padding: theme.spacing(2),
},
},
switchBox: {
padding: theme.spacing(1, 0),
},
switch: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
},
buttonContainer: {
display: 'flex',
margin: theme.spacing(1),
justifyContent: 'center',
'& .MuiButton-root': {
margin: theme.spacing(1),
},
},
backdrop: {
zIndex: theme.zIndex.drawer + 1,
color: '#fff',
},
labelMultiline: {
padding: theme.spacing(2),
textAlign: 'center',
backgroundColor: theme.palette.background.default,
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
upload: {
padding: theme.spacing(2, 2, 0, 2),
},
}),
)
interface IPostsFormInput {
postsTitle: string
noticeAt: boolean
postsContent: string
postsAnswerContent: string
}
export interface IPostsItemsProps {
boardNo: number
postsNo: number
board: BoardSavePayload | null
initData: PostsSavePayload | null
}
const PostsItem = ({ boardNo, postsNo, board, initData }: IPostsItemsProps) => {
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
// 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
// 상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
const uploadRef = useRef<UploadType>()
const [attachData, setAttachData] = useState<
IAttachmentResponse[] | undefined
>(undefined)
// alert
const [customAlert, setCustomAlert] = useState<any>({
open: false,
message: '',
handleAlert: () => setCustomAlert({ open: false }),
})
// Editor
const [postsContent, setPostsContent] = useState<string>(
initData?.postsContent || '',
)
const [postsAnswerContent, setPostsAnswerContent] = useState<string>(
initData?.postsAnswerContent || '',
)
// form hook
const methods = useForm<IPostsFormInput>({
defaultValues: {
postsTitle: initData?.postsTitle || '',
noticeAt:
typeof initData?.noticeAt !== 'undefined' ? initData?.noticeAt : false,
},
})
const {
formState: { errors },
control,
handleSubmit,
} = methods
const successCallback = () => {
setSuccessSnackBar('success')
route.back()
}
const errorCallback = (error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
}
const getAttachments = useCallback(
async (code: string) => {
try {
const result = await fileService.getAttachmentList(code)
if (result) {
setAttachData(result.data)
}
} catch (error) {
setErrorState({
error,
})
}
},
[setErrorState],
)
useEffect(() => {
if (initData.attachmentCode) {
getAttachments(initData.attachmentCode)
}
}, [getAttachments, initData.attachmentCode])
// handleSubmit 저장
const handleSave = async (formData: IPostsFormInput) => {
setSuccessSnackBar('loading')
let { attachmentCode } = initData
try {
const postsContentValue = board.editorUseAt
? postsContent
: formData.postsContent
if (!postsContentValue) {
setCustomAlert({
open: true,
message: format(t('valid.required.format'), [
t('posts.posts_content'),
]),
handleAlert: () => {
setCustomAlert({ open: false })
},
})
return
}
if (board.uploadUseAt) {
const isUpload = await uploadRef.current.isModified(attachData)
if (isUpload) {
const info: UploadInfoReqeust = {
entityName: 'posts',
entityId: board.boardNo?.toString(),
}
// 업로드 및 저장
const result = await uploadRef.current.upload(info, attachData)
if (result) {
if (result !== 'no attachments' && result !== 'no update list') {
attachmentCode = result
}
}
}
}
const data: PostsSavePayload = {
boardNo,
postsTitle: formData.postsTitle,
noticeAt: formData.noticeAt,
postsContent: postsContentValue,
postsAnswerContent: board.editorUseAt
? postsAnswerContent
: formData.postsAnswerContent,
attachmentCode,
}
if (postsNo === -1) {
await postsService.save({
boardNo,
callback: successCallback,
errorCallback,
data,
})
} else {
await postsService.update({
boardNo,
postsNo,
callback: successCallback,
errorCallback,
data,
})
}
} catch (error) {
setErrorState({
error,
})
if (postsNo === -1) {
uploadRef.current?.rollback(attachmentCode)
}
}
}
// 저장 버튼
const saveButton: IButtonProps = {
label: t('label.button.save'),
variant: 'contained',
color: 'primary',
confirmMessage: t('msg.confirm.save'),
handleButton: handleSubmit(handleSave),
}
// 이전 화면으로 이동
const handlePrev = useCallback(() => {
/* if (postsNo === -1) {
route.push(
{
pathname: `/posts/${boardNo}`,
query: {
size: route.query.size,
page: route.query.page,
keywordType: route.query.keywordType,
keyword: route.query.keyword,
},
},
// `/posts/${boardNo}`,
)
} else {
route.push(
{
pathname: `/posts/${boardNo}/view/${postsNo}`,
query: {
size: route.query.size,
page: route.query.page,
keywordType: route.query.keywordType,
keyword: route.query.keyword,
},
},
// `/posts/${boardNo}`,
)
} */
route.back()
}, [route])
// 이전 버튼
const prevButton: IButtonProps = {
label: t('label.button.prev'),
variant: 'contained',
handleButton: handlePrev,
}
return (
<div className={classes.root}>
<FormProvider {...methods}>
<form>
<Grid container spacing={1}>
<Grid item xs={12} sm={12}>
<Box boxShadow={1}>
<Controller
name="postsTitle"
render={({ field }) => (
<TextField
autoFocus
label={t('posts.posts_title')}
name="postsTitle"
required
inputProps={{ maxLength: 100 }}
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('posts.posts_title'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
control={control}
rules={{ required: true, maxLength: 100 }}
/>
{errors.postsTitle && (
<ValidationAlert
fieldError={errors.postsTitle}
target={[100]}
label={t('posts.posts_title')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={12}>
<Box boxShadow={1} className={classes.switchBox}>
<FormControlLabel
label={t('posts.notice_at')}
labelPlacement="start"
control={
<Controller
name="noticeAt"
control={control}
render={({ field: { onChange, ref, value } }) => (
<Switch
inputProps={{ 'aria-label': 'secondary checkbox' }}
onChange={onChange}
inputRef={ref}
checked={value}
/>
)}
/>
}
/>
</Box>
</Grid>
<Grid item xs={12} sm={12}>
{board.editorUseAt && (
<Editor contents={postsContent} setContents={setPostsContent} />
)}
{!board.editorUseAt && (
<Box boxShadow={1}>
<Controller
name="postsContent"
control={control}
rules={{ required: true }}
render={({ field }) => (
<TextField
label={t('posts.posts_content')}
name="postsContent"
multiline
minRows={9.2}
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('posts.posts_content'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.postsContent && (
<ValidationAlert
fieldError={errors.postsContent}
label={t('posts.posts_content')}
/>
)}
</Box>
)}
</Grid>
{(board.skinTypeCode === SKINT_TYPE_CODE_FAQ ||
board.skinTypeCode === SKINT_TYPE_CODE_QNA) && (
<Grid item xs={12} sm={12}>
{board.editorUseAt && (
<Editor
contents={postsAnswerContent}
setContents={setPostsAnswerContent}
/>
)}
{!board.editorUseAt && (
<Box boxShadow={1}>
<Controller
name="postsAnswerContent"
control={control}
render={({ field }) => (
<TextField
label={t('posts.posts_answer_content')}
name="postsAnswerContent"
multiline
minRows={9.2}
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('posts.posts_answer_content'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
</Box>
)}
</Grid>
)}
{board.uploadUseAt && (
<Grid item xs={12} sm={12}>
<Box boxShadow={1}>
<Upload
ref={uploadRef}
multi
uploadLimitCount={board.uploadLimitCount}
uploadLimitSize={board.uploadLimitSize}
attachmentCode={initData.attachmentCode}
attachData={attachData}
/>
{attachData && (
<AttachList data={attachData} setData={setAttachData} />
)}
</Box>
</Grid>
)}
</Grid>
</form>
</FormProvider>
<CustomButtons buttons={[saveButton, prevButton]} />
<CustomAlert
contentText={customAlert.message}
open={customAlert.open}
handleAlert={() => setCustomAlert({ open: false })}
/>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
const boardNo = Number(query.board)
const postsNo = Number(query.id)
let board = {}
let data = {}
try {
if (postsNo !== -1) {
const result = await postsService.get(boardNo, postsNo)
if (result) {
board = (await result.data.board) as BoardSavePayload
data = (await result.data) as PostsSavePayload
}
} else {
const result = await boardService.get(boardNo)
if (result) {
board = (await result.data) as BoardSavePayload
}
}
} catch (error) {
console.error(`posts item query error ${error.message}`)
if (error.response?.data?.code === 'E003') {
return {
notFound: true,
}
}
}
return {
props: {
boardNo,
postsNo,
board,
initData: data,
},
}
}
export default PostsItem

View File

@@ -0,0 +1,552 @@
import { CustomButtons, IButtonProps } from '@components/Buttons'
import CustomAlert from '@components/CustomAlert'
import Search, { IKeywordType } from '@components/Search'
import CustomDataGrid from '@components/Table/CustomDataGrid'
// 내부 컴포넌트 및 custom hook, etc...
import { convertStringToDateFormat } from '@libs/date'
import { Box } from '@material-ui/core'
import Link from '@material-ui/core/Link'
// material-ui deps
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import {
GridColDef,
GridValueFormatterParams,
GridValueGetterParams,
} from '@material-ui/data-grid'
import FiberNewIcon from '@material-ui/icons/FiberNew'
import { ClassNameMap } from '@material-ui/styles'
// api
import {
BoardSavePayload,
boardService,
CommentDeletePayload,
postsService,
} from '@service'
import {
conditionAtom,
detailButtonsSnackAtom,
errorStateSelector,
} from '@stores'
import { format, Page, rownum } from '@utils'
import { AxiosError } from 'axios'
import classNames from 'classnames'
import { GetServerSideProps } from 'next'
import { TFunction, useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useCallback, useMemo, useRef, useState } from 'react'
// 상태관리 recoil
import { useRecoilValue, useSetRecoilState } from 'recoil'
// material-ui style
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
vMiddle: {
verticalAlign: 'middle',
},
mgl: {
marginLeft: theme.spacing(0.5),
},
cancel: {
textDecoration: 'line-through',
},
}),
)
// 그리드 컬럼 정의
type ColumnsType = (
data: Page,
handleDetail: (postsNo: number) => void,
gridApiRef: React.MutableRefObject<any>,
t?: TFunction,
classes?: ClassNameMap<string>,
) => GridColDef[]
const getColumns: ColumnsType = (
data,
handleDetail,
gridApiRef,
t,
classes,
) => [
{
field: 'rownum',
headerName: t('common.no'),
headerAlign: 'center',
align: 'center',
width: 80,
sortable: false,
valueGetter: (params: GridValueGetterParams) =>
rownum(data, params.api.getRowIndex(params.id), 'asc'),
},
{
field: 'postsTitle',
headerName: t('posts.posts_title'),
headerAlign: 'center',
align: 'left',
flex: 1,
sortable: false,
renderCell: function renderCellPostsTitle(params: GridValueGetterParams) {
// eslint-disable-next-line no-param-reassign
gridApiRef.current = params.api // api
return (
<Link
href="#"
onClick={(event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault()
handleDetail(params.row.postsNo)
}}
>
<Box
color="text.primary"
component="span"
className={classNames({
[classes.cancel]: params.row.deleteAt,
})}
>
{(params.row.noticeAt ? `[${t('common.notice')}] ` : '') +
params.row.postsTitle}
{params.row.commentCount && params.row.commentCount !== 0 ? (
<Box
color="red"
component="span"
>{` [${params.row.commentCount}]`}</Box>
) : (
''
)}
{params.row.isNew && (
<FiberNewIcon
color="secondary"
className={classNames({
[classes.mgl]: true,
[classes.vMiddle]: true,
})}
/>
)}
</Box>
</Link>
)
},
},
{
field: 'createdDate',
headerName: t('common.created_datetime'),
headerAlign: 'center',
align: 'center',
width: 200,
sortable: false,
valueFormatter: (params: GridValueFormatterParams) =>
params.value
? convertStringToDateFormat(
params.value as string,
'yyyy-MM-dd HH:mm:ss',
)
: null,
},
{
field: 'createdName',
headerName: t('common.created_by'),
headerAlign: 'center',
align: 'center',
width: 150,
sortable: false,
},
{
field: 'readCount',
headerName: t('common.read_count'),
headerAlign: 'center',
align: 'center',
width: 120,
sortable: false,
},
{
field: 'deleteAt',
headerName: t('label.button.delete'),
headerAlign: 'center',
align: 'center',
width: 120,
sortable: false,
valueGetter: (params: GridValueGetterParams) => {
if (params.value === 1) return '작성자'
if (params.value === 2) return '관리자'
return ''
},
},
]
const conditionKey = 'posts'
export interface IBoardProps {
board: BoardSavePayload | null
}
// 실제 render되는 컴포넌트
const Posts = ({ board }: IBoardProps) => {
// props 및 전역변수
// const { id } = props
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
const gridApiRef = useRef<any>(null)
// 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
// 조회조건 select items
const searchTypes: IKeywordType[] = [
{
key: 'postsData',
label: `${t('posts.posts_title')}+${t('posts.posts_content')}`,
},
{
key: 'postsName',
label: t('posts.posts_title'),
},
{
key: 'postsContent',
label: t('posts.posts_content'),
},
]
/**
* 상태관리 필요한 훅
*/
// 상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
// 조회조건 상태관리
const keywordState = useRecoilValue(conditionAtom(conditionKey))
const [boardNo] = useState<number>(Number(route.query.board) || null)
// 현 페이지내 필요한 hook
const [page, setPage] = useState<number>(
parseInt(route.query.page as string, 10) || 0,
)
const [customAlert, setCustomAlert] = useState<any>({
open: false,
message: '',
})
// 목록 데이터 조회 및 관리
const { data, mutate } = postsService.search(boardNo, {
keywordType: keywordState?.keywordType || 'postsName',
keyword: keywordState?.keyword || '',
size: board.postDisplayCount,
page,
})
/**
* 비지니스 로직
*/
// 에러 callback
const errorCallback = useCallback(
(error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
},
[setErrorState, setSuccessSnackBar],
)
// 목록 조회
const handleSearch = () => {
if (page === 0) {
mutate()
} else {
setPage(0)
}
}
// 상세 화면 이동
const handleDetail = useCallback(
(postsNo: number) => {
route.push({
pathname: `/posts/${boardNo}/view/${postsNo}`,
/* query: {
size: board.postDisplayCount,
page,
keywordType: keywordState?.keywordType,
keyword: keywordState?.keyword,
}, */
})
},
[boardNo, route],
)
// 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const columns = useMemo(
() => getColumns(data, handleDetail, gridApiRef, t, classes),
[data, handleDetail, t, classes, gridApiRef],
)
// datagrid page change event
const handlePageChange = (_page: number, details?: any) => {
setPage(_page)
}
// 그리드 체크 해제
const uncheckedGrid = useCallback(() => {
const selectedRowKeys = gridApiRef.current?.getSelectedRows().keys()
let cnt = 0
while (cnt < data.numberOfElements) {
const gridRowId = selectedRowKeys.next()
if (gridRowId.done === true) break
gridApiRef.current.selectRow(gridRowId.value, false, false)
cnt += 1
}
}, [data?.numberOfElements])
// 선택된 행 수 반환
const getSelectedRowCount = (deleteAt: boolean) => {
let count = 0
const selectedRows = gridApiRef.current.getSelectedRows()
selectedRows.forEach(m => {
if (deleteAt === null || deleteAt ? m.deleteAt !== 0 : m.deleteAt === 0) {
count += 1
}
})
return count
}
// 선택된 행 반환
const getSelectedRows = (deleteAt: boolean) => {
let list: CommentDeletePayload[] = []
const selectedRows = gridApiRef.current.getSelectedRows()
selectedRows.forEach(m => {
if (
deleteAt === null ||
(deleteAt ? m.deleteAt !== 0 : m.deleteAt === 0)
) {
const saved: CommentDeletePayload = {
boardNo: m.boardNo,
postsNo: m.postsNo,
}
list.push(saved)
}
})
return list
}
// 성공 callback
const successCallback = useCallback(() => {
setSuccessSnackBar('success')
uncheckedGrid()
mutate()
}, [mutate, setSuccessSnackBar, uncheckedGrid])
// 삭제
const handleRemove = useCallback(() => {
const selectedRows = getSelectedRows(false)
if (selectedRows.length === 0) {
successCallback()
return
}
postsService.remove({
callback: successCallback,
errorCallback,
data: selectedRows,
})
}, [errorCallback, successCallback])
// 복원
const handleRestore = useCallback(() => {
setSuccessSnackBar('loading')
const selectedRows = getSelectedRows(true)
if (selectedRows.length === 0) {
successCallback()
return
}
postsService.restore({
callback: successCallback,
errorCallback,
data: selectedRows,
})
}, [setSuccessSnackBar, errorCallback, successCallback])
// 완전 삭제
const handleDelete = useCallback(() => {
setSuccessSnackBar('loading')
const selectedRows = getSelectedRows(null)
if (selectedRows.length === 0) {
successCallback()
return
}
postsService.delete({
callback: successCallback,
errorCallback,
data: selectedRows,
})
}, [setSuccessSnackBar, errorCallback, successCallback])
// 삭제 버튼
const removeButton: IButtonProps = {
label: t('label.button.selection_delete'),
variant: 'outlined',
color: 'secondary',
size: 'small',
confirmMessage: t('msg.confirm.delete'),
handleButton: handleRemove,
validate: () => {
if (gridApiRef.current.getSelectedRows().size === 0) {
setCustomAlert({
open: true,
message: format(t('valid.selection.format'), [
`${t('label.button.delete')} ${t('common.target')}`,
]),
})
return false
}
const count = getSelectedRowCount(false) // 미삭제만
if (count === 0) {
setCustomAlert({
open: true,
message: format(t('valid.selection.already_deleted.format'), [
t('authorization'),
]),
})
return false
}
return true
},
completeMessage: t('msg.success.delete'),
}
// 복원 버튼
const restoreButton: IButtonProps = {
label: t('label.button.selection_restore'),
variant: 'outlined',
color: 'primary',
size: 'small',
confirmMessage: t('msg.confirm.restore'),
handleButton: handleRestore,
validate: () => {
if (gridApiRef.current.getSelectedRows().size === 0) {
setCustomAlert({
open: true,
message: format(t('valid.selection.format'), [
`${t('label.button.restore')} ${t('common.target')}`,
]),
})
return false
}
const count = getSelectedRowCount(true) // 삭제만
if (count === 0) {
setCustomAlert({
open: true,
message: format(t('valid.selection.already_restored.format'), [
t('authorization'),
]),
})
return false
}
return true
},
completeMessage: t('msg.success.restore'),
}
// 완전 삭제 버튼
const deleteButton: IButtonProps = {
label: t('label.button.selection_permanent_delete'),
variant: 'outlined',
color: 'secondary',
size: 'small',
confirmMessage: t('msg.confirm.permanent_delete'),
handleButton: handleDelete,
validate: () => {
if (gridApiRef.current.getSelectedRows().size === 0) {
setCustomAlert({
open: true,
message: format(t('valid.selection.format'), [
`${t('label.button.permanent_delete')} ${t('common.target')}`,
]),
})
return false
}
return true
},
completeMessage: t('msg.success.permanent_delete'),
}
return (
<div className={classes.root}>
<Search
keywordTypeItems={searchTypes}
handleSearch={handleSearch}
handleRegister={() => {
route.push(`${boardNo}/edit/-1`)
}}
conditionKey={conditionKey}
/>
<CustomDataGrid
page={page}
classes={classes}
rows={data?.content}
columns={columns}
rowCount={data?.totalElements}
paginationMode="server"
pageSize={board.postDisplayCount}
onPageChange={handlePageChange}
getRowId={r => r.postsNo}
checkboxSelection
disableSelectionOnClick
/>
<CustomButtons
buttons={[removeButton, restoreButton, deleteButton]}
className="containerLeft"
/>
<CustomAlert
contentText={customAlert.message}
open={customAlert.open}
handleAlert={() => setCustomAlert({ open: false })}
/>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
const boardNo = Number(query.board)
let data = {}
try {
if (boardNo !== -1) {
const result = await boardService.get(boardNo)
if (result) {
data = (await result.data) as BoardSavePayload
}
}
} catch (error) {
console.error(`board item query error ${error.message}`)
}
return {
props: {
board: data,
},
}
}
export default Posts

View File

@@ -0,0 +1,458 @@
import AttachList from '@components/AttachList'
import { CustomButtons, IButtonProps } from '@components/Buttons'
import { Comment } from '@components/comment'
import { convertStringToDateFormat } from '@libs/date'
import Box from '@material-ui/core/Box'
import Grid from '@material-ui/core/Grid'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Typography from '@material-ui/core/Typography'
import CommentIcon from '@material-ui/icons/Comment'
import {
BoardSavePayload,
boardService,
fileService,
IAttachmentResponse,
IBoardProps,
PostsSavePayload,
postsService,
SKINT_TYPE_CODE_FAQ,
SKINT_TYPE_CODE_NORMAL,
SKINT_TYPE_CODE_QNA,
} from '@service'
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
import { AxiosError } from 'axios'
import classNames from 'classnames'
import { GetServerSideProps } from 'next'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useCallback, useEffect, useState } from 'react'
import { useSetRecoilState } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginTop: theme.spacing(1),
'& .MuiOutlinedInput-input': {
padding: theme.spacing(2),
},
},
content: {
position: 'relative',
padding: theme.spacing(2),
minHeight: '120px',
},
contentTitle: {
marginTop: theme.spacing(0),
},
contentCreator: {
marginTop: theme.spacing(1),
display: 'flex',
},
contentCreatorLeft: {
flex: 1,
},
commentIcon: {
marginRight: theme.spacing(0.5),
verticalAlign: 'middle',
},
contentLabel: {
display: 'block',
position: 'absolute',
left: '30px',
top: '40px',
width: '40px',
height: '40px',
fontSize: '20px',
fontWeight: 700,
textAlign: 'center',
lineHeight: '40px',
borderRadius: '50%',
color: '#fff',
backgroundColor: '#1a4890',
},
contentLabelQ: {
backgroundColor: '#1a4890',
},
contentLabelA: {
backgroundColor: '#5aab34',
},
contentEditor: {
padding: theme.spacing(2, 2, 2, 10),
},
label: {
padding: theme.spacing(2),
textAlign: 'center',
backgroundColor: theme.palette.background.default,
},
number: {
padding: theme.spacing(2),
textAlign: 'right',
},
mgt1: {
marginTop: theme.spacing(1),
},
mgl3: {
marginLeft: theme.spacing(3),
},
}),
)
export interface IPostsItemsProps {
boardNo: number
postsNo: number
board: BoardSavePayload | null
initData: PostsSavePayload | null
}
const PostsItem = ({ boardNo, postsNo, board, initData }: IPostsItemsProps) => {
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
// 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
// 상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
const [deleteAt, setDeleteAt] = useState<number>(initData.deleteAt)
const [commentCount, setCommentCount] = useState<number>(0)
const refreshCommentCount = count => {
setCommentCount(count)
}
const [attachData, setAttachData] = useState<
IAttachmentResponse[] | undefined
>(undefined)
const getAttachments = useCallback(
async (code: string) => {
try {
const result = await fileService.getAttachmentList(code)
if (result) {
setAttachData(result.data)
}
} catch (error) {
setErrorState({
error,
})
}
},
[setErrorState],
)
useEffect(() => {
if (initData.attachmentCode) {
getAttachments(initData.attachmentCode)
}
}, [getAttachments, initData.attachmentCode])
// 목록 화면으로 이동
const handleList = useCallback(() => {
/* route.push(
{
pathname: `/posts/${boardNo}`,
query: {
size: route.query.size,
page: route.query.page,
keywordType: route.query.keywordType,
keyword: route.query.keyword,
},
},
// `/posts/${boardNo}`,
) */
route.back()
}, [route])
// 수정 화면으로 이동
const handleEdit = useCallback(() => {
route.push({
pathname: `/posts/${boardNo}/edit/${postsNo}`,
query: {
size: route.query.size,
page: route.query.page,
keywordType: route.query.keywordType,
keyword: route.query.keyword,
},
})
}, [boardNo, postsNo, route])
// 에러 callback
const errorCallback = useCallback(
(error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
},
[setErrorState, setSuccessSnackBar],
)
// 삭제
const handleRemove = useCallback(() => {
setSuccessSnackBar('loading')
postsService.remove({
callback: () => {
setSuccessSnackBar('success')
setDeleteAt(2) // 삭제 여부 - 1:작성자, 2:관리자
},
errorCallback,
data: [
{
boardNo,
postsNo,
},
],
})
}, [setSuccessSnackBar, errorCallback, boardNo, postsNo])
// 완전 삭제
const handleDelete = useCallback(() => {
setSuccessSnackBar('loading')
postsService.delete({
callback: () => {
setSuccessSnackBar('success')
handleList() // 목록 화면으로 이동
},
errorCallback,
data: [
{
boardNo,
postsNo,
},
],
})
}, [setSuccessSnackBar, errorCallback, boardNo, postsNo, handleList])
// 복원
const handleRestore = useCallback(() => {
setSuccessSnackBar('loading')
postsService.restore({
callback: () => {
setSuccessSnackBar('success')
setDeleteAt(0)
},
errorCallback,
data: [
{
boardNo,
postsNo,
},
],
})
}, [setSuccessSnackBar, errorCallback, boardNo, postsNo])
// 삭제 버튼
const removeButton: IButtonProps = {
label: t('label.button.delete'),
variant: 'outlined',
color: 'secondary',
size: 'small',
confirmMessage: t('msg.confirm.delete'),
handleButton: handleRemove,
}
// 복원 버튼
const restoreButton: IButtonProps = {
label: t('label.button.restore'),
variant: 'outlined',
color: 'primary',
size: 'small',
confirmMessage: t('msg.confirm.restore'),
handleButton: handleRestore,
}
// 완전 삭제 버튼
const deleteButton: IButtonProps = {
label: t('label.button.permanent_delete'),
variant: 'outlined',
color: 'secondary',
size: 'small',
confirmMessage: t('msg.confirm.permanent_delete'),
handleButton: handleDelete,
}
// 수정 버튼
const editButton: IButtonProps = {
label: t('label.button.edit'),
variant: 'outlined',
color: 'primary',
size: 'small',
handleButton: handleEdit,
}
// 목록 버튼
const listButton: IButtonProps = {
label: t('label.button.list'),
variant: 'outlined',
size: 'small',
handleButton: handleList,
}
// 하단 버튼
let leftButtons = []
// 삭제/복원 버튼 추가
if (deleteAt === 0) {
leftButtons.push(removeButton)
} else {
leftButtons.push(restoreButton)
}
leftButtons.push(deleteButton)
return (
<div className={classes.root}>
<Grid container spacing={1}>
<Grid item xs={12} sm={12}>
<Box boxShadow={1} className={classes.content}>
<Typography variant="h3">
{(initData.noticeAt ? '[공지] ' : '') + initData.postsTitle}
</Typography>
<Box className={classes.contentCreator}>
<Box className={classes.contentCreatorLeft}>
<Typography variant="h6" component="h4">
{initData.createdName}
</Typography>
<Box component="span">
{convertStringToDateFormat(
initData.createdDate,
'yyyy-MM-dd HH:mm:ss',
)}
</Box>
<Box component="span" className={classes.mgl3}>
{`${t('common.read')} ${initData.readCount}`}
</Box>
</Box>
{board?.commentUseAt && (
<Box>
<CommentIcon
fontSize="small"
className={classes.commentIcon}
/>
{`${t('comment')} ${commentCount}`}
</Box>
)}
</Box>
</Box>
</Grid>
<Grid item xs={12} sm={12}>
{board.uploadUseAt && attachData && (
<AttachList data={attachData} setData={setAttachData} readonly />
)}
</Grid>
<Grid item xs={12} sm={12}>
{(board.skinTypeCode === SKINT_TYPE_CODE_FAQ ||
board.skinTypeCode === SKINT_TYPE_CODE_QNA) && (
<Box boxShadow={1} className={classes.content}>
<div
className={classNames({
[classes.contentLabel]: true,
[classes.contentLabelQ]: true,
})}
>
Q
</div>
<div
className={classes.contentEditor}
dangerouslySetInnerHTML={{ __html: initData.postsContent }}
/>
</Box>
)}
{board.skinTypeCode === SKINT_TYPE_CODE_NORMAL && (
<Box boxShadow={1} className={classes.content}>
<div
dangerouslySetInnerHTML={{ __html: initData.postsContent }}
/>
</Box>
)}
</Grid>
{(board.skinTypeCode === SKINT_TYPE_CODE_FAQ ||
board.skinTypeCode === SKINT_TYPE_CODE_QNA) && (
<Grid item xs={12} sm={12}>
<Box boxShadow={1} className={classes.content}>
<div
className={classNames({
[classes.contentLabel]: true,
[classes.contentLabelA]: true,
})}
>
A
</div>
<div
className={classes.contentEditor}
dangerouslySetInnerHTML={{
__html: initData.postsAnswerContent,
}}
/>
</Box>
</Grid>
)}
</Grid>
{board?.commentUseAt && (
<Comment
boardNo={boardNo}
postsNo={postsNo}
commentUseAt={board.commentUseAt}
deleteAt={deleteAt}
refreshCommentCount={refreshCommentCount}
/>
)}
<CustomButtons buttons={leftButtons} className="containerLeft" />
<CustomButtons
buttons={[editButton, listButton]}
className="containerRight"
/>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
const boardNo = Number(query.board)
const postsNo = Number(query.id)
let board: IBoardProps
let data = {}
try {
if (postsNo !== -1) {
const result = await postsService.get(boardNo, postsNo)
if (result) {
board = (await result.data?.board) as IBoardProps
data = (await result.data) as PostsSavePayload
}
} else {
const result = await boardService.get(boardNo)
if (result) {
board = (await result.data) as IBoardProps
}
}
} catch (error) {
console.error(`posts item query error ${error.message}`)
if (error.response?.data?.code === 'E003') {
return {
notFound: true,
}
}
}
return {
props: {
boardNo,
postsNo,
board,
initData: data,
},
}
}
export default PostsItem

View File

@@ -0,0 +1,262 @@
import { DetailButtons } from '@components/Buttons'
import CustomAlert from '@components/CustomAlert'
import ValidationAlert from '@components/EditForm/ValidationAlert'
import Editor from '@components/Editor'
import Box from '@material-ui/core/Box'
import FormControlLabel from '@material-ui/core/FormControlLabel'
import Grid from '@material-ui/core/Grid'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import TextField from '@material-ui/core/TextField'
import { PrivacySavePayload, privacyService } from '@service'
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
import { format } from '@utils'
import { AxiosError } from 'axios'
import { GetServerSideProps } from 'next'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useState } from 'react'
import { Controller, FormProvider, useForm } from 'react-hook-form'
import { useSetRecoilState } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginTop: theme.spacing(1),
'& .MuiOutlinedInput-input': {
padding: theme.spacing(2),
},
},
switchBox: {
padding: theme.spacing(1, 0),
},
switch: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
},
buttonContainer: {
display: 'flex',
margin: theme.spacing(1),
justifyPrivacy: 'center',
'& .MuiButton-root': {
margin: theme.spacing(1),
},
},
backdrop: {
zIndex: theme.zIndex.drawer + 1,
color: '#fff',
},
}),
)
interface IPrivacyFormInput {
privacyTitle: string
privacyContent: string
useAt: boolean
}
export interface IPrivacyItemsProps {
privacyNo: string
initData: PrivacySavePayload | null
}
const PrivacyItem = ({ privacyNo, initData }: IPrivacyItemsProps) => {
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
// 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
// 상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
// alert
const [customAlert, setCustomAlert] = useState<any>({
open: false,
message: '',
handleAlert: () => setCustomAlert({ open: false }),
})
// Editor
const [privacyContent, setPrivacyContent] = useState<string>(
initData?.privacyContent || '',
)
// form hook
const methods = useForm<IPrivacyFormInput>({
defaultValues: {
privacyTitle: initData?.privacyTitle || '',
useAt: typeof initData?.useAt !== 'undefined' ? initData?.useAt : true,
},
})
const {
formState: { errors },
control,
handleSubmit,
} = methods
const successCallback = () => {
setSuccessSnackBar('success')
route.back()
}
const errorCallback = (error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
}
// handleSubmit 저장
const handleSave = async (formData: IPrivacyFormInput) => {
setSuccessSnackBar('loading')
const saved: PrivacySavePayload = {
privacyTitle: formData.privacyTitle,
privacyContent,
useAt: formData.useAt,
}
if (!privacyContent) {
setCustomAlert({
open: true,
message: format(t('valid.required.format'), [
t('privacy.privacy_content'),
]),
handleAlert: () => {
setCustomAlert({ open: false })
},
})
return
}
if (privacyNo === '-1') {
await privacyService.save({
callback: successCallback,
errorCallback,
data: saved,
})
} else {
await privacyService.update({
privacyNo,
callback: successCallback,
errorCallback,
data: saved,
})
}
}
return (
<div className={classes.root}>
<FormProvider {...methods}>
<form>
<Grid container spacing={1}>
<Grid item xs={12} sm={12}>
<Box boxShadow={1}>
<Controller
name="privacyTitle"
control={control}
rules={{ required: true, maxLength: 100 }}
render={({ field }) => (
<TextField
label={t('privacy.privacy_title')}
name="privacyTitle"
required
autoFocus
inputProps={{ maxLength: 100 }}
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('privacy.privacy_title'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.privacyTitle && (
<ValidationAlert
fieldError={errors.privacyTitle}
target={[100]}
label={t('privacy.privacy_title')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={12}>
<Box boxShadow={1} className={classes.switchBox}>
<FormControlLabel
label={t('common.use_at')}
labelPlacement="start"
control={
<Controller
name="useAt"
control={control}
render={({ field: { onChange, ref, value } }) => (
<Switch
inputProps={{ 'aria-label': 'secondary checkbox' }}
onChange={onChange}
inputRef={ref}
checked={value}
/>
)}
/>
}
/>
</Box>
</Grid>
</Grid>
<Editor contents={privacyContent} setContents={setPrivacyContent} />
</form>
</FormProvider>
<DetailButtons
handleList={() => {
route.back()
}}
handleSave={handleSubmit(handleSave)}
/>
<CustomAlert
contentText={customAlert.message}
open={customAlert.open}
handleAlert={() => setCustomAlert({ open: false })}
/>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
const privacyNo = query.id
let data = {}
try {
if (privacyNo !== '-1') {
const result = await privacyService.get(privacyNo as string)
if (result) {
data = (await result.data) as PrivacySavePayload
}
}
} catch (error) {
console.error(`privacy item query error ${error.message}`)
if (error.response?.data?.code === 'E003') {
return {
notFound: true,
}
}
}
return {
props: {
privacyNo,
initData: data,
},
}
}
export default PrivacyItem

View File

@@ -0,0 +1,273 @@
import { GridButtons } from '@components/Buttons'
import Search, { IKeywordType } from '@components/Search'
import CustomDataGrid from '@components/Table/CustomDataGrid'
import { GRID_PAGE_SIZE } from '@constants'
import usePage from '@hooks/usePage'
// 내부 컴포넌트 및 custom hook, etc...
import { convertStringToDateFormat } from '@libs/date'
// material-ui deps
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import {
GridCellParams,
GridColDef,
GridValueFormatterParams,
GridValueGetterParams,
} from '@material-ui/data-grid'
// api
import { privacyService } from '@service'
import {
conditionAtom,
detailButtonsSnackAtom,
errorStateSelector,
} from '@stores'
import { Page, rownum } from '@utils'
import { AxiosError } from 'axios'
import { NextPage } from 'next'
import { TFunction, useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useCallback, useMemo } from 'react'
// 상태관리 recoil
import { useRecoilValue, useSetRecoilState } from 'recoil'
// material-ui style
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
}),
)
// 그리드 컬럼 정의
type ColumnsType = (
data: Page,
toggleUseAt,
deletePrivacy: (privacyNo: string) => void,
updatePrivacy: (privacyNo: string) => void,
t?: TFunction,
) => GridColDef[]
const getColumns: ColumnsType = (
data,
toggleUseAt,
deletePrivacy,
updatePrivacy,
t,
) => [
{
field: 'rownum',
headerName: t('common.no'),
headerAlign: 'center',
align: 'center',
width: 80,
sortable: false,
valueGetter: (params: GridValueGetterParams) =>
rownum(data, params.api.getRowIndex(params.id), 'asc'),
},
{
field: 'privacyTitle',
headerName: t('privacy.privacy_title'),
headerAlign: 'center',
align: 'left',
flex: 1,
sortable: false,
},
{
field: 'useAt',
headerName: t('common.use_at'),
headerAlign: 'center',
align: 'left',
width: 150,
sortable: false,
renderCell: function renderCellCreatedAt(params: GridCellParams) {
return (
<Switch
checked={Boolean(params.value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
toggleUseAt(event, params.row.privacyNo as number)
}
/>
)
},
},
{
field: 'createdDate',
headerName: t('common.created_datetime'),
headerAlign: 'center',
align: 'center',
width: 200,
sortable: false,
valueFormatter: (params: GridValueFormatterParams) =>
convertStringToDateFormat(params.value as string, 'yyyy-MM-dd HH:mm:ss'),
},
{
field: 'buttons',
headerName: t('common.manage'),
headerAlign: 'center',
align: 'center',
width: 150,
sortable: false,
renderCell: function renderCellButtons(params: GridCellParams) {
return (
<GridButtons
id={params.row.privacyNo as string}
handleDelete={deletePrivacy}
handleUpdate={updatePrivacy}
/>
)
},
},
]
const conditionKey = 'privacy'
// 실제 render되는 컴포넌트
const Privacy: NextPage<any> = () => {
// props 및 전역변수
// const { id } = props
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
// 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
// 조회조건 select items
const searchTypes: IKeywordType[] = [
{
key: 'privacyTitle',
label: t('privacy.privacy_title'),
},
]
/**
* 상태관리 필요한 훅
*/
// 조회조건 상태관리
const keywordState = useRecoilValue(conditionAtom(conditionKey))
const setErrorState = useSetRecoilState(errorStateSelector)
// 현 페이지내 필요한 hook
const { page, setPageValue } = usePage(conditionKey)
// 목록 데이터 조회 및 관리
const { data, mutate } = privacyService.search({
keywordType: keywordState?.keywordType || 'privacyTitle',
keyword: keywordState?.keyword || '',
size: GRID_PAGE_SIZE,
page,
})
/**
* 비지니스 로직
*/
// 에러 callback
const errorCallback = useCallback(
(error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
},
[setErrorState, setSuccessSnackBar],
)
// 성공 callback
const successCallback = useCallback(() => {
setSuccessSnackBar('success')
mutate()
}, [mutate, setSuccessSnackBar])
// 사용 여부 toggle 시 save
const toggleUseAt = useCallback(
async (
event: React.ChangeEvent<HTMLInputElement>,
paramPrivacyNo: string,
) => {
setSuccessSnackBar('loading')
await privacyService.updateUseAt({
callback: successCallback,
errorCallback,
privacyNo: paramPrivacyNo,
useAt: event.target.checked,
})
},
[errorCallback, mutate],
)
// 삭제
const deletePrivacy = useCallback(
(privacyNo: string) => {
setSuccessSnackBar('loading')
privacyService.delete({
privacyNo,
callback: successCallback,
errorCallback,
})
},
[errorCallback, mutate],
)
// 수정 시 상세 화면 이동
const updatePrivacy = useCallback(
(privacyNo: string) => {
route.push(`/privacy/${privacyNo}`)
},
[route],
)
// 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const columns = useMemo(
() => getColumns(data, toggleUseAt, deletePrivacy, updatePrivacy, t),
[data, toggleUseAt, deletePrivacy, updatePrivacy, t],
)
// 목록 조회
const handleSearch = () => {
if (page === 0) {
mutate()
} else {
setPageValue(0)
}
}
// datagrid page change event
const handlePageChange = (_page: number, details?: any) => {
setPageValue(_page)
}
return (
<div className={classes.root}>
<Search
keywordTypeItems={searchTypes}
handleSearch={handleSearch}
handleRegister={() => {
route.push('privacy/-1')
}}
conditionKey={conditionKey}
/>
<CustomDataGrid
page={page}
classes={classes}
rows={data?.content}
columns={columns}
rowCount={data?.totalElements}
paginationMode="server"
pageSize={GRID_PAGE_SIZE}
onPageChange={handlePageChange}
getRowId={r => r.privacyNo}
/>
</div>
)
}
export default Privacy

View File

@@ -0,0 +1,80 @@
import { Button } from '@material-ui/core'
import Card from '@material-ui/core/Card'
import CardContent from '@material-ui/core/CardContent'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Typography from '@material-ui/core/Typography'
import Alert, { Color } from '@material-ui/lab/Alert'
import React, { useState } from 'react'
const useStyles = makeStyles((_: Theme) =>
createStyles({
alert: {
margin: _.spacing(1),
},
content: {
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBottom: '2rem',
},
}),
)
type Props = {
initialLoginStatus: string
}
function Home(props: Props) {
const classes = useStyles(props)
const [reloadState, setReloadSteate] = useState<{
message: string
severity: Color
}>({
message: 'reload message!!',
severity: 'info',
})
const onClickReload = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
fetch('/api/v1/messages')
.then(async response => {
const result = await response.json()
if (response.ok) {
setReloadSteate({
message: result.message,
severity: 'success',
})
} else {
setReloadSteate({
message: result.message,
severity: 'error',
})
}
})
.catch(error => {
setReloadSteate({
message: error.message,
severity: 'error',
})
})
}
return (
<Card>
<CardContent className={classes.content}>
<Typography variant="h5" component="h2">
Reload Messages
</Typography>
<Alert className={classes.alert} severity={reloadState.severity}>
{reloadState.message}
</Alert>
<Button variant="outlined" color="primary" onClick={onClickReload}>
Reload
</Button>
</CardContent>
</Card>
)
}
export default Home

View File

@@ -0,0 +1,228 @@
import { DetailButtons } from '@components/Buttons'
import {
ReserveItemAdditional,
ReserveItemBasic,
ReserveItemManager,
} from '@components/ReserveItem'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import {
ICode,
ILocation,
IReserveItem,
locationService,
reserveItemService,
} from '@service'
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
import produce from 'immer'
import { GetServerSideProps } from 'next'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useEffect } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { useSetRecoilState } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginTop: theme.spacing(1),
'& .MuiOutlinedInput-input': {
padding: theme.spacing(2),
},
},
card: {
width: '100%',
},
cardActions: {
justifyContent: 'center',
},
switch: {
width: '100%',
justifyContent: 'start',
border: '1px solid rgba(0, 0, 0, 0.23)',
borderRadius: theme.spacing(0.5),
padding: theme.spacing(1),
marginTop: theme.spacing(1),
},
}),
)
interface ReserveItemDetailProps {
reserveItemId: string
initData?: IReserveItem
locations: ILocation[]
categories: ICode[]
reserveMethods: ICode[]
reserveMeans: ICode[]
selectionMeans: ICode[]
targets: ICode[]
}
const ReserveItemDetail = (props: ReserveItemDetailProps) => {
const { reserveItemId, initData, targets, ...rest } = props
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
//form hook
const methods = useForm<IReserveItem>({
defaultValues: initData,
})
const { register, formState, control, handleSubmit, setFocus, getValues } =
methods
//상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
// <목록, 저장> 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
useEffect(() => {
if (formState.errors) {
setFocus('reserveItemName')
}
}, [formState.errors])
const handleSave = async (formData: IReserveItem) => {
setSuccessSnackBar('loading')
try {
formData = produce(formData, draft => {
draft.isPaid = Boolean(draft.isPaid)
draft.isPeriod = Boolean(draft.isPeriod)
draft.isUse = Boolean(draft.isUse)
})
let result
if (reserveItemId === '-1') {
formData = produce(formData, draft => {
draft.inventoryQty = draft.totalQty
})
result = await reserveItemService.save(formData)
} else {
formData = produce(formData, draft => {
draft.inventoryQty =
draft.totalQty - draft.prevTotalQty + draft.inventoryQty
})
result = await reserveItemService.update(
parseInt(reserveItemId),
formData,
)
}
if (result) {
setSuccessSnackBar('success')
handleList()
}
} catch (error) {
setSuccessSnackBar('none')
setErrorState({ error })
}
}
const handleList = () => {
route.push('/reserve-item')
}
return (
<div className={classes.root}>
<FormProvider {...methods}>
<ReserveItemBasic
control={control}
formState={formState}
register={register}
getValues={getValues}
data={initData}
{...rest}
/>
<ReserveItemAdditional
control={control}
formState={formState}
targets={targets}
/>
<ReserveItemManager control={control} formState={formState} />
<DetailButtons
handleSave={handleSubmit(handleSave)}
handleList={handleList}
/>
</FormProvider>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
const reserveItemId = query.id as string
let locations: ILocation[] = []
let categories: ICode[] = []
let reserveMethods: ICode[] = []
let reserveMeans: ICode[] = []
let selectionMeans: ICode[] = []
let targets: ICode[] = []
try {
locations = await (await locationService.getList()).data
categories = await (
await reserveItemService.getCode('reserve-category')
).data
reserveMethods = await (
await reserveItemService.getCode('reserve-method')
).data
reserveMeans = await (
await reserveItemService.getCode('reserve-means')
).data
selectionMeans = await (
await reserveItemService.getCode('reserve-selection')
).data
targets = await (await reserveItemService.getCode('reserve-target')).data
} catch (error) {
console.error(`reserve item query error ${error.message}`)
}
if (reserveItemId === '-1') {
return {
props: {
reserveItemId,
categories,
locations,
reserveMethods,
reserveMeans,
selectionMeans,
targets,
},
}
}
let data = {}
try {
const result = await reserveItemService.get(parseInt(reserveItemId))
if (result) {
data = (await result.data) as IReserveItem
}
} catch (error) {
console.error(`reserve item query error ${error.message}`)
if (error.response?.data?.code === 'E003') {
return {
notFound: true,
}
}
}
return {
props: {
reserveItemId,
initData: data,
categories,
locations,
reserveMethods,
reserveMeans,
selectionMeans,
targets,
},
}
}
export default ReserveItemDetail

View File

@@ -0,0 +1,360 @@
import { GridButtons } from '@components/Buttons'
import { PopupProps } from '@components/DialogPopup'
import Search from '@components/Search'
import CustomDataGrid from '@components/Table/CustomDataGrid'
import { GRID_PAGE_SIZE } from '@constants'
import usePage from '@hooks/usePage'
import useSearchTypes from '@hooks/useSearchType'
import { convertStringToDateFormat } from '@libs/date'
import Box from '@material-ui/core/Box'
import Button from '@material-ui/core/Button'
import MenuItem from '@material-ui/core/MenuItem'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import TextField from '@material-ui/core/TextField'
import {
GridCellParams,
GridColDef,
GridValueFormatterParams,
GridValueGetterParams,
} from '@material-ui/data-grid'
import { ICode, ILocation, locationService, reserveItemService } from '@service'
import { conditionAtom, conditionValue, errorStateSelector } from '@stores'
import { Page, rownum } from '@utils'
import { GetServerSideProps } from 'next'
import { useRouter } from 'next/router'
import React, { useCallback, useMemo, useState } from 'react'
import { TFunction, useTranslation } from 'react-i18next'
import { useRecoilValue, useSetRecoilState } from 'recoil'
// material-ui style
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
search: {
padding: theme.spacing(1),
textAlign: 'center',
width: '10vw',
maxWidth: 100,
minWidth: 80,
},
}),
)
const conditionKey = 'reserve-item'
type ColumnType = (
data: Page,
handleUpdate: (id: number) => void,
toggleIsUse: (event: React.ChangeEvent<HTMLInputElement>, id: number) => void,
t: TFunction,
handlePopup?: (row: any) => void,
) => GridColDef[]
//그리드 컬럼 정의
const getColumns: ColumnType = (
data: Page,
handleUpdate: (id: number) => void,
toggleIsUse: (event: React.ChangeEvent<HTMLInputElement>, id: number) => void,
t,
handlePopup?: (row: any) => void,
) => {
return [
{
field: 'rownum',
headerName: t('common.no'),
headerAlign: 'center',
align: 'center',
flex: 0.5,
sortable: false,
valueGetter: (params: GridValueGetterParams) =>
rownum(data, params.api.getRowIndex(params.id), 'asc'),
},
{
field: 'locationName',
headerName: t('location'),
headerAlign: 'center',
align: 'center',
flex: 1,
sortable: false,
},
{
field: 'categoryName',
headerName: t('reserve_item.type'),
headerAlign: 'center',
align: 'center',
flex: 1,
sortable: false,
},
{
field: 'reserveItemName',
headerName: t('reserve_item.name'),
headerAlign: 'center',
align: 'left',
flex: 1.5,
sortable: false,
},
{
field: 'totalQty',
headerName: `${t('reserve.count')}/${t('reserve.number_of_people')}`,
headerAlign: 'center',
align: 'right',
flex: 0.8,
sortable: false,
},
{
field: 'isUse',
headerName: t('common.use_at'),
headerAlign: 'center',
align: 'center',
hide: handlePopup ? true : false,
sortable: false,
flex: 1,
renderCell: (params: GridCellParams) => (
<Switch
checked={Boolean(params.value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
toggleIsUse(event, params.row.reserveItemId)
}
/>
),
},
{
field: 'createDate',
headerName: t('common.created_datetime'),
headerAlign: 'center',
align: 'center',
sortable: false,
flex: 1,
valueFormatter: (params: GridValueFormatterParams) =>
convertStringToDateFormat(
params.value as string,
'yyyy-MM-dd HH:mm:ss',
),
},
{
field: 'reserveItemId',
headerName: handlePopup ? t('common.select') : t('common.manage'),
headerAlign: 'center',
align: 'center',
sortable: false,
renderCell: (params: GridCellParams) => {
return handlePopup ? (
<Button
onClick={() => {
handlePopup(params.row)
}}
variant="outlined"
color="inherit"
size="small"
>
{t('common.select')}
</Button>
) : (
<GridButtons
id={params.value as string}
handleUpdate={handleUpdate}
/>
)
},
},
]
}
export type ReserveItemProps = PopupProps & {
locations?: ILocation[]
categories?: ICode[]
}
const ReserveItem = (props: ReserveItemProps) => {
const { handlePopup, locations, categories } = props
const classes = useStyles()
const { t } = useTranslation()
const router = useRouter()
//조회조건 상태관리
const keywordState = useRecoilValue(conditionAtom(conditionKey))
// 에러 상태관리
const setErrorState = useSetRecoilState(errorStateSelector)
const [customKeyword, setCustomKeyword] = useState<conditionValue | null>({
locationId: keywordState?.locationId || '0',
categoryId: keywordState?.categoryId || 'all',
})
const { page, setPageValue } = usePage(conditionKey, 0)
//조회조건 select items
const searchTypes = useSearchTypes([
{
key: 'item',
label: t('reserve_item.name'),
},
])
//목록 데이터 조회 및 관리
const { data, mutate } = reserveItemService.search({
keywordType: keywordState?.keywordType || 'item',
keyword: keywordState?.keyword || '',
size: GRID_PAGE_SIZE,
page,
locationId:
keywordState?.locationId !== '0' ? keywordState?.locationId : null,
categoryId:
keywordState?.categoryId !== 'all' ? keywordState?.categoryId : null,
isUse: Boolean(handlePopup),
})
//목록 조회
const handleSearch = () => {
if (page === 0) {
mutate()
} else {
setPageValue(0)
}
}
const handleRegister = () => {
router.push('/reserve-item/-1')
}
//datagrid page change event
const handlePageChange = (page: number, details?: any) => {
setPageValue(page)
}
const handleUpdate = (id: number) => {
router.push(`/reserve-item/${id}`)
}
const handleCategoryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCustomKeyword({
...customKeyword,
categoryId: e.target.value,
})
}
const handleLocationChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCustomKeyword({
...customKeyword,
locationId: e.target.value,
})
}
//사용여부 toggle 시 바로 update
const toggleIsUse = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>, id: number) => {
try {
const result = await reserveItemService.updateUse(
id,
event.target.checked,
)
if (result?.status === 204) {
mutate()
}
} catch (error) {
setErrorState({ error })
}
},
[customKeyword, page],
)
// 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const columns = useMemo(() => {
return getColumns(data, handleUpdate, toggleIsUse, t, handlePopup)
}, [data])
return (
<div className={classes.root}>
<Search
keywordTypeItems={searchTypes}
handleSearch={handleSearch}
handleRegister={handlePopup ? undefined : handleRegister}
conditionKey={conditionKey}
isNotWrapper={true}
customKeyword={customKeyword}
conditionNodes={
<>
<Box className={classes.search}>
<TextField
id="select-location"
select
value={customKeyword.locationId}
onChange={handleLocationChange}
variant="outlined"
fullWidth
>
<MenuItem key="location-all" value="0">
<em>{t('common.all')}</em>
</MenuItem>
{locations &&
locations.map(option => (
<MenuItem key={option.locationId} value={option.locationId}>
{option.locationName}
</MenuItem>
))}
</TextField>
</Box>
<Box className={classes.search}>
<TextField
id="select-category"
select
value={customKeyword.categoryId}
onChange={handleCategoryChange}
variant="outlined"
fullWidth
>
<MenuItem key="category-all" value="all">
<em>{t('common.all')}</em>
</MenuItem>
{categories &&
categories.map(option => (
<MenuItem key={option.codeId} value={option.codeId}>
{option.codeName}
</MenuItem>
))}
</TextField>
</Box>
</>
}
/>
<CustomDataGrid
classes={classes}
rows={data?.content}
columns={columns}
rowCount={data?.totalElements}
paginationMode="server"
pageSize={GRID_PAGE_SIZE}
page={page}
onPageChange={handlePageChange}
getRowId={r => r.reserveItemId}
/>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
let locations: ILocation[] = []
let categories: ICode[] = []
try {
locations = await (await locationService.getList()).data
categories = await (
await reserveItemService.getCode('reserve-category')
).data
} catch (error) {
console.error(`reserve item query error ${error.message}`)
}
return {
props: {
categories,
locations,
},
}
}
export default ReserveItem

View File

@@ -0,0 +1,305 @@
import { DetailButtons } from '@components/Buttons'
import DialogPopup from '@components/DialogPopup'
import {
ReserveClientInfo,
ReserveInfo,
ReserveInfoView,
ReserveItemInfo,
} from '@components/Reserve'
import { UploadType } from '@components/Upload'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import ReserveItem from '@pages/reserve-item'
import {
fileService,
IAttachmentResponse,
ICode,
IReserve,
IReserveItem,
IReserveItemRelation,
reserveItemService,
ReserveSavePayload,
reserveService,
} from '@service'
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
import { GetServerSideProps } from 'next'
import { useRouter } from 'next/router'
import React, { useEffect, useRef, useState } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { useSetRecoilState } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginTop: theme.spacing(1),
'& .MuiOutlinedInput-input': {
padding: theme.spacing(2),
},
},
}),
)
interface ReserveDetailProps {
reserveId?: string
initData?: IReserve
reserveItem?: IReserveItemRelation
status?: ICode[]
}
const ReserveDetail = (props: ReserveDetailProps) => {
const { reserveId, reserveItem, initData, status } = props
const classes = useStyles()
const router = useRouter()
const { t } = useTranslation()
const uploadRef = useRef<UploadType>()
// 상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
//form hook
const methods = useForm<IReserve>({
defaultValues: initData,
})
const {
register,
formState,
control,
handleSubmit,
clearErrors,
getValues,
setValue,
setError,
} = methods
const [item, setItem] = useState<IReserveItemRelation>(undefined)
const [dialogOpen, setDialogOpen] = useState<boolean>(false)
const [attachData, setAttachData] = useState<
IAttachmentResponse[] | undefined
>(undefined)
useEffect(() => {
if (initData?.attachmentCode) {
fileService
.getAttachmentList(initData.attachmentCode)
.then(result => {
if (result?.data) {
setAttachData(result.data)
}
})
.catch(error => setErrorState({ error }))
}
}, [initData])
useEffect(() => {
if (reserveItem) {
setItem(reserveItem)
}
}, [reserveItem])
const handlePopup = async (data: IReserveItem) => {
if (data) {
try {
const result = await reserveItemService.getWithRelation(
data.reserveItemId,
)
if (result) {
setItem(result.data)
clearErrors()
}
} catch (error) {
setErrorState({ error })
}
}
handleDialogClose()
}
const handleDialogOpen = () => {
setDialogOpen(true)
}
const handleDialogClose = () => {
setDialogOpen(false)
}
const handleSave = async (formData: IReserve) => {
setSuccessSnackBar('loading')
let attachCode = initData?.attachmentCode
try {
attachCode = await uploadRef.current?.upload(
{
entityName: 'reserve',
entityId: null,
},
attachData,
)
// 관리자가 예약하는 경우 심사/실시간 할 것없이 무조건 예약확정(status=approve)
const saveData: ReserveSavePayload = {
...formData,
reserveItemId: item.reserveItemId,
reserveStatusId: 'approve',
locationId: item.locationId,
categoryId: item.categoryId,
attachmentCode: attachCode === 'no attachments' ? null : attachCode,
}
let result
if (reserveId === '-1') {
result = await reserveService.save(saveData)
} else {
result = await reserveService.update(reserveId, saveData)
}
if (result) {
setSuccessSnackBar('success')
handleList()
}
} catch (error) {
setSuccessSnackBar('none')
setErrorState({ error })
if (reserveId === '-1') {
// 저장 실패한 경우 첨부파일 rollback
uploadRef.current?.rollback(attachCode)
}
}
}
const handleList = () => {
router.push('/reserve')
}
const handleButtonStatus = async (status: string, reason?: string) => {
setSuccessSnackBar('loading')
try {
let result
if (status === 'cancel') {
result = await reserveService.cancel(reserveId, reason)
} else {
result = await reserveService.approve(reserveId)
}
if (result) {
setSuccessSnackBar('success')
handleList()
}
} catch (error) {
setSuccessSnackBar('none')
setErrorState({ error })
}
}
return (
<div className={classes.root}>
{item && (
<ReserveItemInfo
data={item}
handleSearchItem={handleDialogOpen}
reserveStatus={status.find(
code => code.codeId === initData?.reserveStatusId,
)}
/>
)}
<DialogPopup
id="find-dialog"
handleClose={handleDialogClose}
open={dialogOpen}
title={`${t('reserve_item')} ${t('label.button.find')}`}
>
<ReserveItem handlePopup={handlePopup} />
</DialogPopup>
{initData?.reserveStatusId ? (
<>
<ReserveInfoView
data={initData}
handleList={handleList}
handleButtons={handleButtonStatus}
attachData={attachData}
/>
</>
) : (
<FormProvider {...methods}>
{item && (
<ReserveInfo
control={control}
formState={formState}
register={register}
getValues={getValues}
data={initData}
item={item}
setError={setError}
clearErrors={clearErrors}
fileProps={{
uploadRef,
attachData,
setAttachData,
}}
/>
)}
<ReserveClientInfo
control={control}
formState={formState}
register={register}
getValues={getValues}
data={initData}
setValue={setValue}
/>
<DetailButtons
handleSave={handleSubmit(handleSave)}
handleList={handleList}
/>
</FormProvider>
)}
</div>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
const { id, reserveItemId } = query
let initData: IReserve = null
let reserveItem: IReserveItemRelation = null
let status: ICode = null
try {
status = await (await reserveItemService.getCode('reserve-status')).data
if (id === '-1') {
const result = await reserveItemService.getWithRelation(
parseInt(reserveItemId as string),
)
if (result) {
reserveItem = result.data
}
} else {
const result = await reserveService.get(id as string)
if (result) {
initData = result.data
reserveItem = initData.reserveItem
}
}
} catch (error) {
console.error(
`reserve detail server side props error occur : ${error.message}`,
)
if (error.response?.data?.code === 'E003') {
return {
notFound: true,
}
}
}
return {
props: {
reserveId: id,
initData,
reserveItem,
status,
},
}
}
export default ReserveDetail

View File

@@ -0,0 +1,363 @@
import Search from '@components/Search'
import CustomDataGrid from '@components/Table/CustomDataGrid'
import { GRID_PAGE_SIZE } from '@constants'
import usePage from '@hooks/usePage'
import useSearchTypes from '@hooks/useSearchType'
import { convertStringToDateFormat } from '@libs/date'
import Box from '@material-ui/core/Box'
import Link from '@material-ui/core/Link'
import MenuItem from '@material-ui/core/MenuItem'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import TextField from '@material-ui/core/TextField'
import Typography from '@material-ui/core/Typography'
import {
GridCellParams,
GridColDef,
GridValueFormatterParams,
GridValueGetterParams,
} from '@material-ui/data-grid'
import { ICode, ILocation, locationService, reserveItemService } from '@service'
import { conditionAtom, conditionValue, errorStateSelector } from '@stores'
import { Page, rownum } from '@utils'
import { GetServerSideProps } from 'next'
import { useRouter } from 'next/router'
import React, { useCallback, useMemo, useState } from 'react'
import { TFunction, useTranslation } from 'react-i18next'
import { useRecoilValue, useSetRecoilState } from 'recoil'
import { reserveService } from 'src/service/Reserve'
// material-ui style
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
search: {
padding: theme.spacing(1),
textAlign: 'center',
width: '10vw',
maxWidth: 100,
minWidth: 80,
},
}),
)
const conditionKey = 'reserve'
type ColumnType = (
props: ReserveListProps,
data: Page,
handleUpdate: (id: number) => void,
toggleIsUse: (event: React.ChangeEvent<HTMLInputElement>, id: number) => void,
t: TFunction,
) => GridColDef[]
//그리드 컬럼 정의
const getColumns: ColumnType = (props, data, handleUpdate, toggleIsUse, t) => {
const { locations, categories, status } = props
return [
{
field: 'rownum',
headerName: t('common.no'),
headerAlign: 'center',
align: 'center',
width: 80,
sortable: false,
valueGetter: (params: GridValueGetterParams) =>
rownum(data, params.api.getRowIndex(params.id), 'asc'),
},
{
field: 'locationId',
headerName: t('location'),
headerAlign: 'center',
align: 'center',
flex: 1,
sortable: false,
renderCell: (params: GridCellParams) => (
<>
{
locations.find(item => item.locationId === params.value)
.locationName
}
</>
),
},
{
field: 'categoryId',
headerName: t('reserve_item.type'),
headerAlign: 'center',
align: 'center',
flex: 1,
sortable: false,
renderCell: (params: GridCellParams) => (
<>{categories.find(item => item.codeId === params.value).codeName}</>
),
},
{
field: 'reserveItemName',
headerName: t('reserve_item.name'),
headerAlign: 'center',
align: 'left',
flex: 1,
sortable: false,
renderCell: (params: GridCellParams) => (
<Typography>
<Link href={`/reserve/${params.row.reserveId}`} variant="body2">
{params.value}
</Link>
</Typography>
),
},
{
field: 'totalQty',
headerName: `${t('reserve.count')}/${t('reserve.number_of_people')}`,
headerAlign: 'center',
align: 'center',
flex: 1,
sortable: false,
},
{
field: 'userName',
headerName: t('reserve.user'),
headerAlign: 'center',
align: 'center',
flex: 1,
sortable: false,
},
{
field: 'reserveStatusId',
headerName: t('reserve.status'),
headerAlign: 'center',
align: 'center',
sortable: false,
renderCell: (params: GridCellParams) => (
<>{status.find(item => item.codeId === params.value).codeName}</>
),
},
{
field: 'createDate',
headerName: t('common.created_datetime'),
headerAlign: 'center',
align: 'center',
sortable: false,
flex: 1,
valueFormatter: (params: GridValueFormatterParams) =>
convertStringToDateFormat(
params.value as string,
'yyyy-MM-dd HH:mm:ss',
),
},
]
}
interface ReserveListProps {
locations?: ILocation[]
categories?: ICode[]
status?: ICode[]
}
const Reserve = (props: ReserveListProps) => {
const { locations, categories } = props
const classes = useStyles()
const { t } = useTranslation()
const router = useRouter()
//조회조건 상태관리
const keywordState = useRecoilValue(conditionAtom(conditionKey))
const [customKeyword, setCustomKeyword] = useState<conditionValue | null>({
locationId: keywordState?.locationId || '0',
categoryId: keywordState?.categoryId || 'all',
})
const { page, setPageValue } = usePage(conditionKey)
// 에러 상태관리
const setErrorState = useSetRecoilState(errorStateSelector)
//조회조건 select items
const searchTypes = useSearchTypes([
{
key: 'item',
label: t('reserve_item.name'),
},
])
//목록 데이터 조회 및 관리
const { data, mutate } = reserveService.search({
keywordType: keywordState?.keywordType || 'item',
keyword: keywordState?.keyword || '',
size: GRID_PAGE_SIZE,
page,
locationId:
keywordState?.locationId !== '0' ? keywordState?.locationId : null,
categoryId:
keywordState?.categoryId !== 'all' ? keywordState?.categoryId : null,
})
//목록 조회
const handleSearch = () => {
if (page === 0) {
mutate(data, false)
} else {
setPageValue(0)
}
}
const handleRegister = () => {
router.push('/reserve/item')
}
//datagrid page change event
const handlePageChange = (page: number, details?: any) => {
setPageValue(page)
}
const handleUpdate = (id: number) => {
router.push(`/reserve-item/${id}`)
}
const handleCategoryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault()
setCustomKeyword({
...customKeyword,
categoryId: e.target.value,
})
}
const handleLocationChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault()
setCustomKeyword({
...customKeyword,
locationId: e.target.value,
})
}
//사용여부 toggle 시 바로 update
const toggleIsUse = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>, id: number) => {
try {
const result = await reserveItemService.updateUse(
id,
event.target.checked,
)
if (result?.status === 204) {
mutate()
}
} catch (error) {
setErrorState({ error })
}
},
[customKeyword],
)
// 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const columns = useMemo(() => {
return getColumns(props, data, handleUpdate, toggleIsUse, t)
}, [props, data, t])
return (
<div className={classes.root}>
<Search
keywordTypeItems={searchTypes}
handleSearch={handleSearch}
handleRegister={handleRegister}
conditionKey={conditionKey}
isNotWrapper={true}
customKeyword={customKeyword}
conditionNodes={
<>
<Box className={classes.search}>
<TextField
id="select-location"
select
value={customKeyword.locationId}
onChange={handleLocationChange}
variant="outlined"
fullWidth
>
<MenuItem key="location-all" value="0">
<em>{t('common.all')}</em>
</MenuItem>
{locations &&
locations.map(option => (
<MenuItem key={option.locationId} value={option.locationId}>
{option.locationName}
</MenuItem>
))}
</TextField>
</Box>
<Box className={classes.search}>
<TextField
id="select-category"
select
value={customKeyword.categoryId}
onChange={handleCategoryChange}
variant="outlined"
fullWidth
>
<MenuItem key="category-all" value="all">
<em>{t('common.all')}</em>
</MenuItem>
{categories &&
categories.map(option => (
<MenuItem key={option.codeId} value={option.codeId}>
{option.codeName}
</MenuItem>
))}
</TextField>
</Box>
</>
}
/>
<CustomDataGrid
classes={classes}
rows={data?.content}
columns={columns}
rowCount={data?.totalElements}
paginationMode="server"
pageSize={GRID_PAGE_SIZE}
page={page}
onPageChange={handlePageChange}
getRowId={r => r.reserveId}
/>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
let locations: ILocation[] = []
let categories: ICode[] = []
let status: ICode[] = []
try {
let result = await locationService.getList()
if (result) {
locations = result.data
}
result = await reserveItemService.getCode('reserve-category')
if (result) {
categories = result.data
}
result = await reserveItemService.getCode('reserve-status')
if (result) {
status = result.data
}
} catch (error) {
console.error(`reserve item query error ${error.message}`)
}
return {
props: {
categories,
locations,
status,
},
}
}
export default Reserve

View File

@@ -0,0 +1,79 @@
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 { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Typography from '@material-ui/core/Typography'
import ReserveItem from '@pages/reserve-item'
import { IReserveItem } from '@service'
import { useRouter } from 'next/router'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
marginTop: theme.spacing(5),
backgroundColor: theme.palette.background.default,
},
content: {
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBottom: '2rem',
},
pos: {
marginTop: theme.spacing(1),
marginBottom: '3rem',
},
}),
)
interface SearchItemProps {}
const SearchItem = (props: SearchItemProps) => {
const classes = useStyles()
const { t } = useTranslation()
const router = useRouter()
const [dialogOpen, setDialogOpen] = useState<boolean>(false)
const handlePopup = (data: IReserveItem) => {
if (data) {
router.push(`/reserve/-1?reserveItemId=${data.reserveItemId}`)
}
handleDialogClose()
}
const handleDialogOpen = () => {
setDialogOpen(true)
}
const handleDialogClose = () => {
setDialogOpen(false)
}
return (
<Card className={classes.root}>
<CardContent className={classes.content}>
<Typography className={classes.pos} variant="h5" color="textSecondary">
{t('reserve.msg.find_item')}
</Typography>
<Button variant="contained" color="primary" onClick={handleDialogOpen}>
{`${t('reserve_item')} ${t('common.search')}`}
</Button>
<DialogPopup
id="find-dialog"
handleClose={handleDialogClose}
open={dialogOpen}
title={`${t('reserve_item')} ${t('label.button.find')}`}
>
<ReserveItem handlePopup={handlePopup} />
</DialogPopup>
</CardContent>
</Card>
)
}
export default SearchItem

View File

@@ -0,0 +1,586 @@
import { CustomButtons, IButtonProps } from '@components/Buttons'
import CustomAlert from '@components/CustomAlert'
import Search, { IKeywordType } from '@components/Search'
import CustomDataGrid from '@components/Table/CustomDataGrid'
import { GRID_PAGE_SIZE, GRID_ROW_HEIGHT } from '@constants'
import usePage from '@hooks/usePage'
// 내부 컴포넌트 및 custom hook, etc...
import { convertStringToDateFormat } from '@libs/date'
import { Button } from '@material-ui/core'
import Box from '@material-ui/core/Box'
// material-ui deps
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import {
DataGrid,
GridCellParams,
GridColDef,
GridValueFormatterParams,
} from '@material-ui/data-grid'
// api
import {
IRole,
RoleAuthorizationSavePayload,
roleAuthorizationService,
roleService,
} from '@service'
import {
conditionAtom,
detailButtonsSnackAtom,
errorStateSelector,
} from '@stores'
import { format, Page } from '@utils'
import { AxiosError } from 'axios'
import { GetServerSideProps } from 'next'
import { TFunction, useTranslation } from 'next-i18next'
import React, { useCallback, useMemo, useRef, useState } from 'react'
// 상태관리 recoil
import { useRecoilValue, useSetRecoilState } from 'recoil'
// material-ui style
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
}),
)
// 권한 그리드 컬럼 정의
type RoleColumnsType = (
data: any[] | [],
handleManageRole: (roleId: string) => void,
t?: TFunction,
) => GridColDef[]
const getRoleColumns: RoleColumnsType = (data, handleManageRole, t) => [
{
field: 'roleId',
headerName: t('role.role_id'),
headerAlign: 'center',
align: 'left',
width: 200,
sortable: false,
},
{
field: 'roleName',
headerName: t('role.role_name'),
headerAlign: 'center',
align: 'center',
width: 200,
sortable: false,
},
{
field: 'roleContent',
headerName: t('role.role_content'),
headerAlign: 'center',
flex: 1,
sortable: false,
},
{
field: 'createdDate',
headerName: t('common.created_datetime'),
headerAlign: 'center',
align: 'center',
width: 200,
sortable: false,
valueFormatter: (params: GridValueFormatterParams) =>
convertStringToDateFormat(params.value as string, 'yyyy-MM-dd HH:mm:ss'),
},
{
field: 'buttons',
headerName: t('common.manage'),
headerAlign: 'center',
align: 'center',
width: 150,
sortable: false,
renderCell: function renderCellRoleId(params: GridCellParams) {
return (
<div>
<Box>
<Button
variant="outlined"
color="primary"
size="small"
/* eslint-disable-next-line @typescript-eslint/no-empty-function */
onClick={() => {
handleManageRole(params.row.roleId)
}}
>
{t('role.manage_authorization')}
</Button>
</Box>
</div>
)
},
},
]
// 인가 그리드 컬럼 정의
type AuthorizationColumnsType = (
data: Page,
toggleCreatedAt: (
event: React.ChangeEvent<HTMLInputElement>,
roleId: string,
authorizationNo: number,
) => void,
roleAuthorizationApiRef: React.MutableRefObject<any>,
t?: TFunction,
) => GridColDef[]
const getAuthorizationColumns: AuthorizationColumnsType = (
data,
toggleCreatedAt,
roleAuthorizationApiRef,
t,
) => [
{
field: 'authorizationName',
headerName: t('authorization.authorization_name'),
headerAlign: 'center',
align: 'left',
width: 250,
sortable: false,
},
{
field: 'urlPatternValue',
headerName: t('authorization.url_pattern_value'),
headerAlign: 'center',
align: 'left',
flex: 1,
sortable: false,
},
{
field: 'httpMethodCode',
headerName: t('authorization.http_method_code'),
headerAlign: 'center',
align: 'center',
width: 140,
sortable: false,
},
{
field: 'sortSeq',
headerName: t('common.sort_seq'),
headerAlign: 'center',
align: 'center',
width: 110,
sortable: false,
},
{
field: 'createdAt',
headerName: t('common.created_at'),
headerAlign: 'center',
align: 'center',
width: 110,
sortable: false,
renderCell: function renderCellCreatedAt(params: GridCellParams) {
// eslint-disable-next-line no-param-reassign
roleAuthorizationApiRef.current = params.api // api
return (
<Switch
checked={Boolean(params.value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
toggleCreatedAt(
event,
params.row.roleId as string,
params.row.authorizationNo as number,
)
}
/>
)
},
},
]
const conditionKey = 'authorization'
export interface IRoleAuthorizationProps {
roles: IRole[]
initRoleId: string
}
// 실제 render되는 컴포넌트
const RoleAuthorization = ({ roles, initRoleId }: IRoleAuthorizationProps) => {
// props 및 전역변수
// const { id } = props
const classes = useStyles()
const roleAuthorizationApiRef = useRef<any>(null)
const { t } = useTranslation()
// 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
// 조회조건 select items
const searchTypes: IKeywordType[] = [
{
key: 'authorizationName',
label: t('authorization.authorization_name'),
},
{
key: 'urlPatternValue',
label: t('authorization.url_pattern_value'),
},
{
key: 'httpMethodCode',
label: t('authorization.http_method_code'),
},
]
/**
* 상태관리 필요한 훅
*/
// 조회조건 상태관리
const keywordState = useRecoilValue(conditionAtom(conditionKey))
const setErrorState = useSetRecoilState(errorStateSelector)
// 현 페이지내 필요한 hook
const [customAlert, setCustomAlert] = useState<any>({
open: false,
message: '',
})
const { page, setPageValue } = usePage(conditionKey)
const [roleId, setRoleId] = useState<string>(initRoleId)
/**
* 비지니스 로직
*/
// 권한 정보 초기화
if (roles) {
let role
if (roleId) {
role = roles.find(m => m.roleId === roleId)
}
if (role === undefined) {
role = roles.find(m => m)
}
if (role !== undefined) {
if (roleId !== role.roleId) setRoleId(role.roleId)
}
}
// 인가 목록 조회
const { data, mutate } = roleAuthorizationService.search(roleId, {
keywordType: keywordState?.keywordType || 'authorizationName',
keyword: keywordState?.keyword || '',
size: GRID_PAGE_SIZE,
page,
})
// 그리드 체크 해제
const uncheckedGrid = useCallback(() => {
const selectedRowKeys = roleAuthorizationApiRef.current
?.getSelectedRows()
.keys()
let cnt = 0
while (cnt < data.numberOfElements) {
const gridRowId = selectedRowKeys.next()
if (gridRowId.done === true) break
roleAuthorizationApiRef.current.selectRow(gridRowId.value, false, false)
cnt += 1
}
}, [data?.numberOfElements])
// 성공 callback
const successCallback = useCallback(() => {
setSuccessSnackBar('success')
uncheckedGrid()
mutate()
}, [mutate, setSuccessSnackBar, uncheckedGrid])
// 에러 callback
const errorCallback = useCallback(
(error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
},
[setErrorState, setSuccessSnackBar],
)
// 인가 toggle 시 save
const toggleCreatedAt = useCallback(
async (
event: React.ChangeEvent<HTMLInputElement>,
paramRoleId: string,
paramAuthorizationNo: number,
) => {
setSuccessSnackBar('loading')
const selectedRow: RoleAuthorizationSavePayload = {
roleId: paramRoleId,
authorizationNo: paramAuthorizationNo,
}
if (event.target.checked) {
await roleAuthorizationService.save({
callback: successCallback,
errorCallback,
data: [selectedRow],
})
} else {
await roleAuthorizationService.delete({
callback: successCallback,
errorCallback,
data: [selectedRow],
})
}
},
[errorCallback, setSuccessSnackBar, successCallback],
)
// 권한매핑관리
const handleManageRole = useCallback((_roleId: string) => {
setRoleId(_roleId)
setPageValue(0)
}, [])
// 권한 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const roleColumns = useMemo(
() => getRoleColumns(roles, handleManageRole, t),
[handleManageRole, roles, t],
)
// 인가 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const authorizationColumns = useMemo(
() =>
getAuthorizationColumns(
data,
toggleCreatedAt,
roleAuthorizationApiRef,
t,
),
[data, toggleCreatedAt, roleAuthorizationApiRef, t],
)
// 선택된 행 수 반환
const getSelectedRowCount = (checked: boolean) => {
let count = 0
const selectedRows = roleAuthorizationApiRef.current.getSelectedRows()
selectedRows.forEach(m => {
if (m.createdAt === checked) {
count += 1
}
})
return count
}
// 선택된 행 반환
const getSelectedRows = (checked: boolean) => {
let list: RoleAuthorizationSavePayload[] = []
const selectedRows = roleAuthorizationApiRef.current.getSelectedRows()
selectedRows.forEach(m => {
if (m.createdAt === checked) {
const saved: RoleAuthorizationSavePayload = {
roleId: m.roleId,
authorizationNo: m.authorizationNo,
}
list.push(saved)
}
})
return list
}
// 선택 저장
const handleSave = useCallback(() => {
setSuccessSnackBar('loading')
const selectedRows = getSelectedRows(false)
if (selectedRows.length === 0) {
successCallback()
return
}
roleAuthorizationService.save({
callback: successCallback,
errorCallback,
data: selectedRows,
})
}, [setSuccessSnackBar, successCallback, errorCallback])
// 선택 삭제
const handleDelete = useCallback(() => {
setSuccessSnackBar('loading')
const selectedRows = getSelectedRows(true)
if (selectedRows.length === 0) {
successCallback()
return
}
roleAuthorizationService.delete({
callback: successCallback,
errorCallback,
data: selectedRows,
})
}, [setSuccessSnackBar, successCallback, errorCallback])
// 선택 등록, 선택 삭제 버튼
const saveButton: IButtonProps = {
label: t('label.button.selection_registration'),
variant: 'outlined',
color: 'default',
size: 'small',
confirmMessage: t('msg.confirm.registration'),
handleButton: handleSave,
validate: () => {
if (roleAuthorizationApiRef.current.getSelectedRows().size === 0) {
setCustomAlert({
open: true,
message: format(t('valid.selection.format'), [
`${t('label.button.reg')} ${t('common.target')}`,
]),
})
return false
}
const count = getSelectedRowCount(false) // 미등록만
if (count === 0) {
setCustomAlert({
open: true,
message: format(t('valid.selection.already_saved.format'), [
t('authorization'),
]),
})
return false
}
return true
},
completeMessage: t('msg.success.save'),
}
const deleteButton: IButtonProps = {
label: t('label.button.selection_delete'),
variant: 'outlined',
color: 'default',
size: 'small',
confirmMessage: t('msg.confirm.delete'),
handleButton: handleDelete,
validate: () => {
if (roleAuthorizationApiRef.current.getSelectedRows().size === 0) {
setCustomAlert({
open: true,
message: format(t('valid.selection.format'), [
`${t('label.button.delete')} ${t('common.target')}`,
]),
})
return false
}
const count = getSelectedRowCount(true) // 등록만
if (count === 0) {
setCustomAlert({
open: true,
message: format(t('valid.selection.already_deleted.format'), [
t('authorization'),
]),
})
return false
}
return true
},
completeMessage: t('msg.success.delete'),
}
// 목록 조회
const handleSearch = () => {
if (page === 0) {
mutate()
} else {
setPageValue(0)
}
}
// datagrid page change event
const handlePageChange = (_page: number, details?: any) => {
setPageValue(_page)
}
return (
<div className={classes.root}>
<DataGrid
rows={roles || []}
columns={roleColumns}
rowHeight={GRID_ROW_HEIGHT}
autoHeight
getRowId={r => r.roleId}
hideFooter
selectionModel={(roles || [])
.filter(r => r.roleId === roleId)
.map(r => r.roleId)}
onSelectionModelChange={newSelection => {
setRoleId(newSelection[0]?.toString())
}}
/>
<Search
keywordTypeItems={searchTypes}
handleSearch={handleSearch}
conditionKey={conditionKey}
/>
<CustomDataGrid
page={page}
classes={classes}
rows={data?.content}
columns={authorizationColumns}
rowCount={data?.totalElements}
paginationMode="server"
pageSize={GRID_PAGE_SIZE}
onPageChange={handlePageChange}
getRowId={r => r.authorizationNo}
checkboxSelection
disableSelectionOnClick
/>
<CustomButtons buttons={[saveButton, deleteButton]} />
<CustomAlert
contentText={customAlert.message}
open={customAlert.open}
handleAlert={() => setCustomAlert({ open: false })}
/>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
const roleId = query.roleId as string
let roles: IRole[] = []
let initRoleId = ''
try {
const result = await roleService.searchAll()
if (result) {
roles = result.data
if (roles) {
if (roleId) {
initRoleId = roles.find(m => m.roleId === roleId).roleId
}
if (!initRoleId) {
initRoleId = roles.find(m => m).roleId
}
}
}
} catch (error) {
console.error(`role list getServerSideProps error ${error.message}`)
}
return {
props: {
roles,
initRoleId,
},
}
}
export default RoleAuthorization

View File

@@ -0,0 +1,235 @@
import React, { useCallback, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import { NextPage } from 'next'
import { TFunction, useTranslation } from 'next-i18next'
// material-ui deps
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import {
GridCellParams,
GridColDef,
GridValueFormatterParams,
GridValueGetterParams,
} from '@material-ui/data-grid'
import Box from '@material-ui/core/Box'
import { Button } from '@material-ui/core'
// 내부 컴포넌트 및 custom hook, etc...
import { convertStringToDateFormat } from '@libs/date'
import CustomDataGrid from '@components/Table/CustomDataGrid'
import Search, { IKeywordType } from '@components/Search'
import { Page, rownum } from '@utils'
// 상태관리 recoil
import { useRecoilValue } from 'recoil'
import { conditionAtom } from '@stores'
// api
import { roleService } from '@service'
import usePage from '@hooks/usePage'
import { GRID_PAGE_SIZE } from '@constants'
// material-ui style
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
search: {
padding: theme.spacing(1),
textAlign: 'center',
},
iconButton: {
padding: theme.spacing(1),
marginLeft: theme.spacing(1),
backgroundColor: theme.palette.background.default,
},
fab: {
marginLeft: theme.spacing(1),
},
}),
)
// 그리드 컬럼 정의
type ColumnsType = (
data: Page,
handleManageRole: (roleId: string) => void,
t?: TFunction,
) => GridColDef[]
const getColumns: ColumnsType = (data, handleManageRole, t) => [
{
field: 'rownum',
headerName: t('common.no'),
headerAlign: 'center',
align: 'center',
sortable: false,
valueGetter: (params: GridValueGetterParams) =>
rownum(data, params.api.getRowIndex(params.id), 'asc'),
},
{
field: 'roleId',
headerName: t('role.role_id'),
headerAlign: 'center',
align: 'left',
width: 200,
sortable: false,
},
{
field: 'roleName',
headerName: t('role.role_name'),
headerAlign: 'center',
align: 'center',
width: 200,
sortable: false,
},
{
field: 'roleContent',
headerName: t('role.role_content'),
headerAlign: 'center',
flex: 1,
sortable: false,
},
{
field: 'createdDate',
headerName: t('common.created_datetime'),
headerAlign: 'center',
align: 'center',
width: 200,
sortable: false,
valueFormatter: (params: GridValueFormatterParams) =>
convertStringToDateFormat(params.value as string, 'yyyy-MM-dd HH:mm:ss'),
},
{
field: 'buttons',
headerName: t('common.manage'),
headerAlign: 'center',
align: 'center',
width: 150,
sortable: false,
renderCell: function renderCellButtons(params: GridCellParams) {
return (
<div>
<Box>
<Button
variant="outlined"
color="primary"
size="small"
/* eslint-disable-next-line @typescript-eslint/no-empty-function */
onClick={() => {
handleManageRole(params.row.roleId)
}}
>
{t('role.manage_authorization')}
</Button>
</Box>
</div>
)
},
},
]
const conditionKey = 'role'
// 실제 render 컴포넌트
const Role: NextPage<any> = () => {
// props 및 전역변수
// const { id } = props
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
// 조회조건 select items
const searchTypes: IKeywordType[] = [
{
key: 'roleName',
label: t('role.role_name'),
},
{
key: 'roleContent',
label: t('role.role_content'),
},
]
/**
* 상태관리 필요한 훅
*/
// 조회조건 상태관리
const keywordState = useRecoilValue(conditionAtom(conditionKey))
// 현 페이지내 필요한 hook
const { page, setPageValue } = usePage(conditionKey)
// 목록 데이터 조회 및 관리
const { data, mutate } = roleService.search({
keywordType: keywordState?.keywordType || 'roleName',
keyword: keywordState?.keyword || '',
size: GRID_PAGE_SIZE,
page,
})
/**
* 비지니스 로직
*/
// 권한 인가 매핑 관리 화면 이동
const handleManageRole = useCallback(
(roleId: string) => {
route.push(
{
pathname: `/role-authorization`,
query: { roleId },
},
'/role-authorization',
)
},
[route],
)
// 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const columns = useMemo(
() => getColumns(data, handleManageRole, t),
[data, handleManageRole, t],
)
// 목록 조회
const handleSearch = () => {
if (page === 0) {
mutate()
} else {
setPageValue(0)
}
}
// datagrid page change event
const handlePageChange = (_page: number, details?: any) => {
setPageValue(_page)
}
return (
<div className={classes.root}>
<Search
keywordTypeItems={searchTypes}
handleSearch={handleSearch}
conditionKey={conditionKey}
/>
<CustomDataGrid
page={page}
classes={classes}
rows={data?.content}
columns={columns}
rowCount={data?.totalElements}
paginationMode="server"
pageSize={GRID_PAGE_SIZE}
onPageChange={handlePageChange}
getRowId={r => r.roleId}
/>
</div>
)
}
export default Role

View File

@@ -0,0 +1,137 @@
import CustomBarChart from '@components/CustomBarChart'
import { format as dateFormat, getCurrentDate } from '@libs/date'
import { Card, CardContent, Typography } from '@material-ui/core'
import MenuItem from '@material-ui/core/MenuItem'
import Select from '@material-ui/core/Select'
import { DailyPayload, ISite, statisticsService } from '@service'
import { GetServerSideProps } from 'next'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
const initDailyPayload: DailyPayload = {
year: parseInt(dateFormat(getCurrentDate(), 'yyyy')),
month: parseInt(dateFormat(getCurrentDate(), 'MM')),
}
const tooltipContent = tooltip => (
<Card variant="outlined">
<CardContent>
<Typography variant="h5">{tooltip}</Typography>
</CardContent>
</Card>
)
interface StatisticsProps {
sites: ISite[]
}
function Statistics(props: StatisticsProps) {
const { sites } = props
const { t } = useTranslation()
const [siteState, setSiteState] = useState<number>(sites[0]?.id)
const [dailyPayload, setDailyPayload] =
useState<DailyPayload>(initDailyPayload)
const { monthly } = statisticsService.getMonthly(siteState)
const { daily } = statisticsService.getDaily(siteState, dailyPayload)
const handleSiteChange = (event: React.ChangeEvent<{ value: unknown }>) => {
setSiteState(event.target.value as number)
setDailyPayload(initDailyPayload)
}
const handleMonthlyClick = (data, index) => {
if (data) {
setDailyPayload({
year: data.year,
month: data.month,
})
}
}
const monthlyTooltipContent = ({ active, payload, label }) => {
if (!active || !payload || !label) return null
return tooltipContent(
`${label} ${t('statistics.month')} : ${payload[0].value}`,
)
}
const dailyTooltipContent = ({ active, payload, label }) => {
if (!active || !payload || !label) return null
return tooltipContent(
`${label} ${t('statistics.day')} : ${payload[0].value}`,
)
}
return (
<div>
<Select
variant="outlined"
fullWidth
value={siteState}
onChange={handleSiteChange}
>
{sites?.map(item => (
<MenuItem key={item.id} value={item.id}>
{item.name}
</MenuItem>
))}
</Select>
{monthly && (
<CustomBarChart
id="monthlyChart"
data={monthly}
tooltipContent={monthlyTooltipContent}
title={`${dailyPayload.year}${t('statistics.year')} ${t(
'statistics.monthly',
)} ${t('statistics.access')}`}
handleCellClick={handleMonthlyClick}
customxAxisTick={(value: any, index: number) => {
return `${value} ${t('statistics.month')}`
}}
/>
)}
{daily && (
<CustomBarChart
id="dailyChart"
data={daily}
tooltipContent={dailyTooltipContent}
title={`${dailyPayload.year}${t('statistics.year')} ${
dailyPayload.month
}${t('statistics.month')} ${t('statistics.daily')} ${t(
'statistics.access',
)}`}
customxAxisTick={(value: any, index: number) => {
return `${value} ${t('statistics.day')}`
}}
/>
)}
</div>
)
}
export const getServerSideProps: GetServerSideProps = async context => {
let sites: ISite[] = []
try {
const result = await statisticsService.getSites()
if (result) {
sites = result.data
}
} catch (error) {
console.error(`statistics getServerSideProps error ${error.message}`)
}
return {
props: {
sites,
},
}
}
export default Statistics

View File

@@ -0,0 +1,431 @@
import { DetailButtons } from '@components/Buttons'
import ValidationAlert from '@components/EditForm/ValidationAlert'
import Box from '@material-ui/core/Box'
import FormControl from '@material-ui/core/FormControl'
import Grid from '@material-ui/core/Grid'
import InputLabel from '@material-ui/core/InputLabel'
import MenuItem from '@material-ui/core/MenuItem'
import Select from '@material-ui/core/Select'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import TextField from '@material-ui/core/TextField'
import {
codeService,
ICode,
IRole,
roleService,
UserSavePayload,
userService,
} from '@service'
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
import { format } from '@utils'
import { AxiosError } from 'axios'
import { GetServerSideProps } from 'next'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React from 'react'
import { Controller, FormProvider, useForm } from 'react-hook-form'
import { useSetRecoilState } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginTop: theme.spacing(1),
'& .MuiOutlinedInput-input': {
padding: theme.spacing(2),
},
},
formControl: {
width: '100%',
},
switch: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
},
buttonContainer: {
display: 'flex',
margin: theme.spacing(1),
justifyContent: 'center',
'& .MuiButton-root': {
margin: theme.spacing(1),
},
},
backdrop: {
zIndex: theme.zIndex.drawer + 1,
color: '#fff',
},
}),
)
interface IUserFormInput {
email: string
password: string
passwordConfirm: string
userName: string
roleId: string
userStateCode: string
}
export interface IUserItemsProps {
userId: string
initData: UserSavePayload | null
roles: IRole[]
userStateCodeList: ICode[]
}
const UserItem = ({
userId,
initData,
roles,
userStateCodeList,
}: IUserItemsProps) => {
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
// 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
// 상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
// form hook
const methods = useForm<IUserFormInput>({
defaultValues: {
email: initData?.email || '',
password: '',
passwordConfirm: '',
userName: initData?.userName || '',
roleId: initData?.roleId || 'ROLE_ANONYMOUS',
userStateCode: initData?.userStateCode || '00',
},
})
const {
formState: { errors },
control,
handleSubmit,
} = methods
const successCallback = () => {
setSuccessSnackBar('success')
route.back()
}
const errorCallback = (error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
}
// handleSubmit 저장
const handleSave = async (formData: IUserFormInput) => {
setSuccessSnackBar('loading')
const saved: UserSavePayload = {
email: formData.email,
password: formData.password,
userName: formData.userName,
roleId: formData.roleId,
userStateCode: formData.userStateCode,
}
if (userId === '-1') {
await userService.save({
callback: successCallback,
errorCallback,
data: saved,
})
} else {
await userService.update({
userId,
callback: successCallback,
errorCallback,
data: saved,
})
}
}
// 비밀번호 형식 확인
const checkPasswordPattern = value =>
/^(?=.*?[a-zA-Z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,20}$/.test(value)
return (
<div className={classes.root}>
<FormProvider {...methods}>
<form>
<Grid container spacing={1}>
<Grid item xs={12} sm={12}>
<Box boxShadow={1}>
<Controller
name="email"
control={control}
rules={{ required: true, maxLength: 100 }}
render={({ field }) => (
<TextField
autoFocus
label={t('user.email')}
name="email"
required
inputProps={{ maxLength: 100 }}
placeholder={format(t('msg.placeholder.format'), [
t('user.email'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.email && (
<ValidationAlert
fieldError={errors.email}
target={[100]}
label={t('user.email')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={12}>
<Box boxShadow={1}>
<Controller
name="password"
control={control}
rules={{
required: userId === '-1',
maxLength: {
value: 20,
message: format(t('valid.maxlength.format'), [20]),
},
validate: value =>
!value ||
checkPasswordPattern(value) ||
(t('valid.password') as string),
}}
render={({ field }) => (
<TextField
type="password"
label={t('user.password')}
name="password"
required={userId === '-1'}
inputProps={{ maxLength: 20 }}
placeholder={format(t('msg.placeholder.format'), [
t('user.password'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.password && (
<ValidationAlert
fieldError={errors.password}
target={[20]}
label={t('user.password')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={12}>
<Box boxShadow={1}>
<Controller
name="passwordConfirm"
control={control}
rules={{
required: userId === '-1',
maxLength: {
value: 20,
message: format(t('valid.maxlength.format'), [20]),
},
validate: value =>
(!methods.getValues().password && !value) ||
(checkPasswordPattern(value) &&
methods.getValues().password === value) ||
(t('valid.password.confirm') as string),
}}
render={({ field }) => (
<TextField
type="password"
label={t('label.title.password_confirm')}
name="passwordConfirm"
required={userId === '-1'}
inputProps={{ maxLength: 20 }}
placeholder={format(t('msg.placeholder.format'), [
t('label.title.password_confirm'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.passwordConfirm && (
<ValidationAlert
fieldError={errors.passwordConfirm}
target={[20]}
label={t('label.title.password_confirm')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={12}>
<Box boxShadow={1}>
<Controller
name="userName"
control={control}
rules={{ required: true, maxLength: 25 }}
render={({ field }) => (
<TextField
label={t('user.user_name')}
name="userName"
required
inputProps={{ maxLength: 25 }}
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('user.user_name'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
{errors.userName && (
<ValidationAlert
fieldError={errors.userName}
target={[25]}
label={t('user.user_name')}
/>
)}
</Box>
</Grid>
<Grid item xs={12} sm={12}>
<FormControl variant="outlined" className={classes.formControl}>
<InputLabel id="roleId-label" required>
{t('role')}
</InputLabel>
<Controller
name="roleId"
control={control}
defaultValue={initData?.roleId || 'ROLE_ANONYMOUS'}
rules={{ required: true }}
render={({ field }) => (
<Select
variant="outlined"
name="roleId"
required
labelId="roleId-label"
label={t('role')}
margin="dense"
{...field}
>
{roles.map(option => (
<MenuItem key={option.roleId} value={option.roleId}>
{option.roleName}
</MenuItem>
))}
</Select>
)}
/>
</FormControl>
</Grid>
<Grid item xs={12} sm={12}>
<FormControl variant="outlined" className={classes.formControl}>
<InputLabel id="userStateCode-label" required>
{t('user.user_state_code')}
</InputLabel>
<Controller
name="userStateCode"
control={control}
defaultValue={initData?.userStateCode || '00'}
rules={{ required: true }}
render={({ field }) => (
<Select
variant="outlined"
name="userStateCode"
required
labelId="roleId-label"
label={t('user.user_state_code')}
margin="dense"
{...field}
>
{userStateCodeList.map(option => (
<MenuItem key={option.codeId} value={option.codeId}>
{option.codeName}
</MenuItem>
))}
</Select>
)}
/>
</FormControl>
</Grid>
</Grid>
</form>
</FormProvider>
<DetailButtons
handleList={() => {
route.back()
}}
handleSave={handleSubmit(handleSave)}
/>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
const userId = query.id
let data = {}
let roles: any[] = []
let userStateCodeList = []
try {
const result = await roleService.searchAll()
if (result) {
roles = result.data
}
} catch (error) {
console.error(`role query error ${error.message}`)
}
try {
const codeList = await codeService.getCodeDetailList('user_state_code')
if (codeList) {
userStateCodeList = (await codeList.data) as ICode[]
}
} catch (error) {
console.error(`codes query error ${error.message}`)
}
try {
if (userId !== '-1') {
const result = await userService.get(userId as string)
if (result) {
data = (await result.data) as UserSavePayload
}
}
} catch (error) {
console.error(`user info query error ${error.message}`)
if (error.response?.data?.code === 'E003') {
return {
notFound: true,
}
}
}
return {
props: {
userId,
initData: data,
roles,
userStateCodeList,
},
}
}
export default UserItem

View File

@@ -0,0 +1,299 @@
import { GridButtons } from '@components/Buttons'
import { PopupProps } from '@components/DialogPopup'
import Search, { IKeywordType } from '@components/Search'
import CustomDataGrid from '@components/Table/CustomDataGrid'
import { GRID_PAGE_SIZE } from '@constants'
import usePage from '@hooks/usePage'
// 내부 컴포넌트 및 custom hook, etc...
import { convertStringToDateFormat } from '@libs/date'
import Button from '@material-ui/core/Button'
// material-ui deps
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import {
GridCellParams,
GridColDef,
GridValueFormatterParams,
GridValueGetterParams,
} from '@material-ui/data-grid'
// api
import { userService } from '@service'
import {
conditionAtom,
detailButtonsSnackAtom,
errorStateSelector,
} from '@stores'
import { Page, rownum } from '@utils'
import { AxiosError } from 'axios'
import { NextPage } from 'next'
import { TFunction, useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useCallback, useMemo } from 'react'
// 상태관리 recoil
import { useRecoilValue, useSetRecoilState } from 'recoil'
// material-ui style
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
}),
)
// 그리드 컬럼 정의
type ColumnsType = (
data: Page,
deleteUser: (userId: string) => void,
updateUser: (userId: string) => void,
t?: TFunction,
handlePopup?: (data: any) => void,
) => GridColDef[]
const getColumns: ColumnsType = (
data,
deleteUser,
updateUser,
t,
handlePopup,
) => [
{
field: 'rownum',
headerName: t('common.no'),
headerAlign: 'center',
align: 'center',
width: 80,
sortable: false,
valueGetter: (params: GridValueGetterParams) =>
rownum(data, params.api.getRowIndex(params.id), 'asc'),
},
{
field: 'email',
headerName: t('user.email'),
headerAlign: 'center',
align: 'left',
flex: 1,
sortable: false,
},
{
field: 'userName',
headerName: t('user.user_name'),
headerAlign: 'center',
align: 'center',
flex: 1,
sortable: false,
},
{
field: 'roleName',
headerName: t('role.role_name'),
headerAlign: 'center',
align: 'center',
flex: 1,
sortable: false,
},
{
field: 'loginFailCount',
headerName: t('user.login_lock_at'),
headerAlign: 'center',
align: 'center',
flex: 1,
sortable: false,
renderCell: function renderCellLoginFailCount(params: GridCellParams) {
return params.row.loginFailCount >= 5 ? '잠김' : '해당없음'
},
},
{
field: 'userStateCodeName',
headerName: t('user.user_state_code'),
headerAlign: 'center',
align: 'center',
flex: 1,
sortable: false,
},
{
field: 'lastLoginDate',
headerName: t('user.last_login_date'),
headerAlign: 'center',
align: 'center',
width: 200,
sortable: false,
valueFormatter: (params: GridValueFormatterParams) =>
params.value === null
? ''
: convertStringToDateFormat(
params.value as string,
'yyyy-MM-dd HH:mm:ss',
),
},
{
field: 'buttons',
headerName: t('common.manage'),
headerAlign: 'center',
align: 'center',
width: 150,
sortable: false,
renderCell: function renderCellButtons(params: GridCellParams) {
return handlePopup ? (
<Button
onClick={() => {
handlePopup(params.row)
}}
variant="outlined"
color="inherit"
size="small"
>
{t('common.select')}
</Button>
) : (
<GridButtons
id={params.row.userId as string}
handleDelete={deleteUser}
handleUpdate={updateUser}
/>
)
},
},
]
const conditionKey = 'user'
export type UserProps = PopupProps
// 실제 render되는 컴포넌트
const User: NextPage<UserProps> = props => {
// props 및 전역변수
const { handlePopup } = props
const classes = useStyles()
const route = useRouter()
const { t } = useTranslation()
// 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
// 조회조건 select items
const searchTypes: IKeywordType[] = [
{
key: 'userName',
label: t('user.user_name'),
},
{
key: 'email',
label: t('user.email'),
},
]
/**
* 상태관리 필요한 훅
*/
// 조회조건 상태관리
const keywordState = useRecoilValue(conditionAtom(conditionKey))
const setErrorState = useSetRecoilState(errorStateSelector)
// 현 페이지내 필요한 hook
const { page, setPageValue } = usePage(conditionKey)
// 목록 데이터 조회 및 관리
const { data, mutate } = userService.search({
keywordType: keywordState?.keywordType || 'userName',
keyword: keywordState?.keyword || '',
size: GRID_PAGE_SIZE,
page,
})
/**
* 비지니스 로직
*/
// 에러 callback
const errorCallback = useCallback(
(error: AxiosError) => {
setSuccessSnackBar('none')
setErrorState({
error,
})
},
[setErrorState, setSuccessSnackBar],
)
// 성공 callback
const successCallback = useCallback(() => {
setSuccessSnackBar('success')
mutate()
}, [mutate, setSuccessSnackBar])
// 삭제
const deleteUser = useCallback(
(userId: string) => {
setSuccessSnackBar('loading')
userService.delete({
userId,
callback: successCallback,
errorCallback,
})
},
[errorCallback, mutate, setSuccessSnackBar],
)
// 수정 시 상세 화면 이동
const updateUser = useCallback(
(userId: string) => {
route.push(`/user/${userId}`)
},
[route],
)
// 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const columns = useMemo(
() => getColumns(data, deleteUser, updateUser, t, handlePopup),
[data, deleteUser, updateUser, t, handlePopup],
)
// 목록 조회
const handleSearch = () => {
if (page === 0) {
mutate()
} else {
setPageValue(0)
}
}
// datagrid page change event
const handlePageChange = (_page: number, details?: any) => {
setPageValue(_page)
}
return (
<div className={classes.root}>
<Search
keywordTypeItems={searchTypes}
handleSearch={handleSearch}
handleRegister={
handlePopup
? null
: () => {
route.push('user/-1')
}
}
conditionKey={conditionKey}
/>
<CustomDataGrid
page={page}
classes={classes}
rows={data?.content}
columns={columns}
rowCount={data?.totalElements}
paginationMode="server"
pageSize={GRID_PAGE_SIZE}
onPageChange={handlePageChange}
getRowId={r => r.userId}
/>
</div>
)
}
export default User