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

29
frontend/admin/src/@types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
interface Window {
__localeId__: string
}
namespace NodeJS {
interface Global {
__localeId__: string
}
}
declare module '*.png' {
const resource: string
export = resource
}
declare module '*.svg' {
const resource: string
export = resource
}
declare module '*.css' {
const resource: any
export = resource
}
declare module '*.pcss' {
const resource: string
export = resource
}
declare module '*.json' {
const resource: any
export = resource
}

View File

@@ -0,0 +1,193 @@
import { Layout } from '@components/Layout'
import Loader from '@components/Loader'
import LoginLayout from '@components/LoginLayout'
import Wrapper from '@components/Wrapper'
import {
ACCESS_LOG_ID,
ACCESS_LOG_TIMEOUT,
DEFAULT_APP_NAME,
DEFAULT_ERROR_MESSAGE,
PUBLIC_PAGES,
} from '@constants'
import { SITE_ID } from '@constants/env'
import useUser from '@hooks/useUser'
import { getCurrentDate } from '@libs/date'
import { common, statisticsService } from '@service'
import {
currentMenuStateAtom,
flatMenusSelect,
ISideMenu,
menuStateAtom,
} from '@stores'
import axios from 'axios'
import { NextComponentType, NextPageContext } from 'next'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { useSnackbar } from 'notistack'
import React, { useCallback, useEffect } from 'react'
import { useCookies } from 'react-cookie'
import { useRecoilState, useRecoilValue } from 'recoil'
import { SWRConfig } from 'swr'
import { v4 as uuidv4 } from 'uuid'
export type AppProps = {
component: NextComponentType<any, any, any>
pathname?: string
req?: NextPageContext['req']
}
const App = ({ component: Component, ...pageProps }: AppProps) => {
const router = useRouter()
const pathname = router.pathname
const authLayout = pathname.startsWith('/auth/')
const isUnAuthPage = pathname !== undefined && authLayout
const { user, loading, isLogin, loggedOut } = useUser()
const [menus, setMenus] = useRecoilState(menuStateAtom)
const [currentMenu, setCurrentMenu] = useRecoilState(currentMenuStateAtom)
const flatMenus = useRecoilValue(flatMenusSelect)
const { enqueueSnackbar } = useSnackbar()
const [cookies, setCookie] = useCookies([ACCESS_LOG_ID])
// access log
useEffect(() => {
if (!authLayout) {
const date = getCurrentDate()
date.setTime(date.getTime() + ACCESS_LOG_TIMEOUT)
if (cookies[ACCESS_LOG_ID]) {
setCookie(ACCESS_LOG_ID, cookies[ACCESS_LOG_ID], {
path: '/',
expires: date,
})
} else {
const uuid = uuidv4()
setCookie(ACCESS_LOG_ID, uuid, { path: '/', expires: date })
try {
statisticsService.save(uuid)
} catch (error) {
console.error('access log save error', error)
}
}
}
}, [router])
useEffect(() => {
if (loggedOut) {
router.replace('/auth/login')
}
}, [loggedOut])
useEffect(() => {
if (!loading && !isUnAuthPage && user === undefined) {
router.replace('/auth/login')
}
}, [user, isUnAuthPage, pathname, loading])
useEffect(() => {
if (isLogin) {
const getMenus = async () => {
const menu = await axios.get(
`/portal-service/api/v1/menu-roles/${SITE_ID}`,
{
headers: common.headers,
},
)
if (menu) {
setMenus(menu.data)
}
}
getMenus()
}
}, [isLogin])
//current menu
const findCurrent = useCallback(
(path: string) => {
return flatMenus.find(item => item.urlPath === path)
},
[menus, pathname],
)
useEffect(() => {
if (!isUnAuthPage) {
let current: ISideMenu | undefined = undefined
let paths = router.asPath
while (true) {
current = findCurrent(paths)
paths = paths.substring(0, paths.lastIndexOf('/'))
if (current || paths.length < 1) {
break
}
}
// 권한 없는 페이지 대해 호출이 있으면 404로 redirect
if (flatMenus.length > 0 && !current) {
if (!PUBLIC_PAGES.includes(router.asPath)) {
router.push('/404')
}
}
setCurrentMenu(current)
}
}, [pathname, menus])
if (loading) {
return <Loader />
}
if (!isUnAuthPage && user == null) {
return null
}
if (!isUnAuthPage && !user) {
return null
}
if (!isUnAuthPage && !(currentMenu || PUBLIC_PAGES.includes(router.asPath))) {
return null
}
return (
<>
<Head>
<title>{currentMenu?.korName || DEFAULT_APP_NAME}</title>
</Head>
{pathname !== undefined && authLayout ? (
<LoginLayout>
<Wrapper>
<Component pathname={pathname} {...pageProps} />
</Wrapper>
</LoginLayout>
) : (
<Layout>
<SWRConfig
value={{
onError: (error, key) => {
let message: string
if (error.response) {
message = error.response.data.message || DEFAULT_ERROR_MESSAGE
} else {
message = DEFAULT_ERROR_MESSAGE
}
enqueueSnackbar(message, {
variant: 'error',
key,
})
},
}}
>
<Wrapper>
<Component pathname={pathname} {...pageProps} />
</Wrapper>
</SWRConfig>
</Layout>
)}
</>
)
}
export default App

View File

@@ -0,0 +1,84 @@
import Chip from '@material-ui/core/Chip'
import Paper from '@material-ui/core/Paper'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import AssignmentReturnedIcon from '@material-ui/icons/AssignmentReturned'
import { fileService, IAttachmentResponse } from '@service'
import produce from 'immer'
import React from 'react'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
display: 'flex',
flexWrap: 'wrap',
listStyle: 'none',
padding: theme.spacing(0),
margin: 0,
},
item: {
padding: theme.spacing(1),
},
chip: {
margin: theme.spacing(0.5),
},
}),
)
export interface AttachListProps {
data: IAttachmentResponse[]
setData?: React.Dispatch<React.SetStateAction<IAttachmentResponse[]>>
readonly?: boolean
}
const AttachList = (props: AttachListProps) => {
const { data, setData, readonly } = props
const classes = useStyles()
const handleClick = (item: IAttachmentResponse) => () => {
let a = document.createElement('a')
a.href = `${fileService.downloadUrl}/${item.id}`
a.download = item.originalFileName
a.click()
}
const handleDelete = (item: IAttachmentResponse) => () => {
setData(
produce(data, draft => {
const idx = draft.findIndex(attachment => attachment.id === item.id)
draft[idx].isDelete = true
}),
)
}
return (
<Paper component="ul" className={classes.root}>
{data &&
data.map(item => {
return item.isDelete ? null : (
<li key={`li-${item.id}`} className={classes.item}>
{readonly ? (
<Chip
key={`chip-${item.id}`}
label={item.originalFileName}
onClick={handleClick(item)}
className={classes.chip}
icon={<AssignmentReturnedIcon />}
/>
) : (
<Chip
key={`chip-${item.id}`}
label={item.originalFileName}
onClick={handleClick(item)}
onDelete={handleDelete(item)}
className={classes.chip}
icon={<AssignmentReturnedIcon />}
/>
)}
</li>
)
})}
</Paper>
)
}
export default AttachList

View File

@@ -0,0 +1,168 @@
import React, { useState } from 'react'
import Container from '@material-ui/core/Container'
import CssBaseline from '@material-ui/core/CssBaseline'
import Avatar from '@material-ui/core/Avatar'
import Typography from '@material-ui/core/Typography'
import LockOutlinedIcon from '@material-ui/icons/LockOutlined'
import TextField from '@material-ui/core/TextField'
import FormControlLabel from '@material-ui/core/FormControlLabel'
import Checkbox from '@material-ui/core/Checkbox'
import Button from '@material-ui/core/Button'
import Alert from '@material-ui/lab/Alert'
import { makeStyles, Theme } from '@material-ui/core/styles'
import { useForm } from 'react-hook-form'
import { PageProps } from '@pages/_app'
import { EmailStorage } from '@libs/Storage/emailStorage'
import { DEFAULT_APP_NAME } from '@constants'
const useStyles = makeStyles((theme: Theme) => ({
paper: {
marginTop: theme.spacing(10),
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
},
avatar: {
margin: theme.spacing(1),
backgroundColor: theme.palette.secondary.main,
},
form: {
width: '100%', // Fix IE 11 issue.
marginTop: theme.spacing(1),
},
submit: {
margin: theme.spacing(3, 0, 2),
},
}))
export type loginFormType = {
email?: string
password?: string
isRemember?: boolean
}
interface ILoginFormProps extends PageProps {
errorMessage?: string
handleLogin: ({ email, password }: loginFormType) => void
}
const LoginForm = ({ handleLogin, errorMessage }: ILoginFormProps) => {
const classes = useStyles()
const emails = new EmailStorage('login')
const [checked, setChecked] = useState<boolean>(emails.get().isRemember)
const {
register,
handleSubmit,
formState: { errors },
getValues,
} = useForm<loginFormType>()
const onSubmit = (formData: loginFormType) => {
setRemember()
handleLogin({
email: formData.email,
password: formData.password,
})
}
const setRemember = () => {
if (checked) {
emails.set({
email: getValues('email'),
isRemember: checked,
})
} else {
emails.clear()
}
}
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setChecked(event.target.checked)
setRemember()
}
return (
<Container component="main" maxWidth="xs">
<CssBaseline />
<div className={classes.paper}>
<Avatar className={classes.avatar}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
{DEFAULT_APP_NAME}
</Typography>
<form
className={classes.form}
noValidate
onSubmit={handleSubmit(onSubmit)}
>
<TextField
variant="outlined"
margin="normal"
required
fullWidth
id="email"
label="Email Address"
name="email"
autoComplete="email"
autoFocus
defaultValue={emails.get().email}
{...register('email', {
required: 'Email Address is required!!',
pattern: {
value:
/^[0-9a-zA-Z]([-_\.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_\.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/i,
message: 'Email Address의 형식이 맞지 않습니다.',
},
})}
/>
{errors.email && (
<Alert severity="warning">{errors.email.message}</Alert>
)}
<TextField
variant="outlined"
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="current-password"
{...register('password', {
required: 'Password is required!!',
})}
/>
{errors.password && (
<Alert severity="warning">{errors.password.message}</Alert>
)}
<FormControlLabel
control={
<Checkbox
value="remember"
color="primary"
onChange={handleChange}
checked={checked}
/>
}
label="아이디 저장"
/>
{errorMessage && <Alert severity="warning">{errorMessage}</Alert>}
<Button
type="submit"
fullWidth
variant="contained"
color="primary"
className={classes.submit}
>
Sign In
</Button>
</form>
</div>
</Container>
)
}
export default LoginForm

View File

@@ -0,0 +1,233 @@
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSnackbar } from 'notistack'
import { useRecoilState } from 'recoil'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Box from '@material-ui/core/Box'
import Button, { ButtonProps } from '@material-ui/core/Button'
import Card from '@material-ui/core/Card'
import CardActions from '@material-ui/core/CardActions'
import CardContent from '@material-ui/core/CardContent'
import Popover from '@material-ui/core/Popover'
import Typography from '@material-ui/core/Typography'
import { detailButtonsSnackAtom } from '@stores'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
},
container: {
display: 'flex',
margin: theme.spacing(1),
justifyContent: 'center',
'& .MuiButton-root': {
margin: theme.spacing(1),
},
},
containerLeft: {
display: 'flex',
float: 'left',
margin: theme.spacing(1, 0),
justifyContent: 'left',
'& .MuiButton-root': {
margin: theme.spacing(1),
},
},
containerRight: {
display: 'flex',
float: 'right',
margin: theme.spacing(1, 0),
justifyContent: 'right',
'& .MuiButton-root': {
margin: theme.spacing(1),
},
},
backdrop: {
zIndex: theme.zIndex.drawer + 1,
color: '#fff',
},
mg0: {
margin: theme.spacing(0),
},
}),
)
export interface IButtonProps extends ButtonProps {
label: string
confirmMessage?: string
validate?: (row?: any) => boolean
handleButton: (row?: any) => void
completeMessage?: string
}
export interface ICustomButtonProps {
buttons: IButtonProps[]
row?: any
className?: string
}
const CustomButtons: React.FC<ICustomButtonProps> = ({
buttons,
row,
className,
}) => {
const classes = useStyles()
const topBoxClass =
typeof className !== 'undefined' ? classes[className] : classes.container
const { t } = useTranslation()
const { enqueueSnackbar } = useSnackbar()
const [isSuccessSnackBar, setSuccessSnackBar] = useRecoilState(
detailButtonsSnackAtom,
)
const [buttonId, setButtonId] = useState<string>(null)
const [message, setMessage] = useState<string>(null)
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null)
const messageOpen = Boolean(anchorEl)
const messagePopId = messageOpen ? 'simple-popover' : undefined
const findButton = useCallback(
(id: string) => {
if (id) {
const buttonIndex = parseInt(id.replace('customButton', ''), 10)
return buttons[buttonIndex]
}
return null
},
[buttons],
)
useEffect(() => {
if (isSuccessSnackBar === 'success') {
const button = findButton(buttonId)
if (button?.completeMessage) {
enqueueSnackbar(button.completeMessage || t('msg.success.save'), {
variant: 'success',
})
}
setSuccessSnackBar('none')
}
}, [
buttonId,
enqueueSnackbar,
findButton,
isSuccessSnackBar,
setSuccessSnackBar,
t,
])
const handlePopover = useCallback(
(target: HTMLButtonElement | null) => {
if (target?.id) {
const button = findButton(target?.id)
if (button) {
setMessage(button.confirmMessage)
setAnchorEl(target)
}
}
},
[findButton],
)
const handleClick = (target: HTMLButtonElement | null) => {
setButtonId(target?.id)
if (target?.id) {
const button = findButton(target?.id)
if (button) {
if (button.validate && !button.validate(row)) return
if (button.confirmMessage) {
handlePopover(target)
} else {
button.handleButton(row)
}
}
}
}
const handleButton = () => {
const button = findButton(anchorEl?.id)
if (button) button.handleButton(row)
setAnchorEl(null)
}
return (
<>
<Box className={topBoxClass}>
{buttons &&
buttons.map((button, index) => {
const {
label,
confirmMessage,
validate,
handleButton,
completeMessage,
...rest
} = button
return (
<Button
key={`customButton${index.toString()}`}
id={`customButton${index.toString()}`}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
handleClick(event.currentTarget)
}}
{...rest}
>
{label}
</Button>
)
})}
<Popover
id={messagePopId}
open={messageOpen}
anchorEl={anchorEl}
onClose={() => {
handlePopover(null)
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
>
<Card>
<CardContent>
<Typography variant="h5">{message}</Typography>
</CardContent>
<CardActions>
<Button
variant="outlined"
onClick={() => {
setAnchorEl(null)
}}
>
{t('label.button.close')}
</Button>
<Button
variant="outlined"
color="secondary"
onClick={handleButton}
>
{t('label.button.confirm')}
</Button>
</CardActions>
</Card>
</Popover>
</Box>
</>
)
}
export { CustomButtons }

View File

@@ -0,0 +1,112 @@
import React, { useEffect, useState } from 'react'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Box from '@material-ui/core/Box'
import Button from '@material-ui/core/Button'
import { Color } from '@material-ui/lab/Alert'
import { useTranslation } from 'react-i18next'
import { ConfirmPopover } from '@components/Confirm'
import { useSnackbar } from 'notistack'
import { detailButtonsSnackAtom } from '@stores'
import { useRecoilState } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
},
container: {
display: 'flex',
margin: theme.spacing(1),
justifyContent: 'center',
'& .MuiButton-root': {
margin: theme.spacing(1),
},
},
backdrop: {
zIndex: theme.zIndex.drawer + 1,
color: '#fff',
},
}),
)
export interface ISnackProps {
severity: Color
message: string
}
export interface IDetailButtonProps {
handleList?: () => void
handleSave?: () => void
saveMessages?: ISnackProps
}
const DetailButtons: React.FC<IDetailButtonProps> = ({
handleList,
handleSave,
saveMessages,
}) => {
const classes = useStyles()
const { t } = useTranslation()
const { enqueueSnackbar } = useSnackbar()
const [isSuccessSnackBar, setSuccessSnackBar] = useRecoilState(
detailButtonsSnackAtom,
)
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null)
useEffect(() => {
if (isSuccessSnackBar === 'success') {
enqueueSnackbar(saveMessages?.message || t('msg.success.save'), {
variant: saveMessages?.severity || 'success',
})
}
if (isSuccessSnackBar !== 'loading') {
setAnchorEl(null)
}
}, [isSuccessSnackBar])
useEffect(() => {
if (anchorEl === null) {
setSuccessSnackBar('none')
}
}, [anchorEl])
const handlePopover = (target: HTMLButtonElement | null) => {
setAnchorEl(target)
}
return (
<>
<Box className={classes.container}>
{handleList && (
<Button variant="contained" onClick={handleList} color="default">
{t('label.button.list')}
</Button>
)}
{handleSave && (
<div>
<Button
variant="contained"
color="primary"
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
handlePopover(event.currentTarget)
}}
>
{t('label.button.save')}
</Button>
<ConfirmPopover
anchorEl={anchorEl}
message={t('msg.confirm.save')}
handleConfirm={handleSave}
handlePopover={handlePopover}
/>
</div>
)}
</Box>
</>
)
}
export { DetailButtons }

View File

@@ -0,0 +1,85 @@
import React, { useState } from 'react'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Box from '@material-ui/core/Box'
import { Button } from '@material-ui/core'
import { useTranslation } from 'react-i18next'
import { ConfirmPopover } from '@components/Confirm'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
display: 'flex',
},
}),
)
export interface IGridButtonProps {
id: string
handleDelete?: (id: string | number) => void
handleUpdate?: (id: string | number) => void
}
const GridButtons: React.FC<IGridButtonProps> = ({
id,
handleDelete,
handleUpdate,
}) => {
const classes = useStyles()
const { t } = useTranslation()
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null)
const onClickUpdate = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
handleUpdate(id)
}
const onClickDelete = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
handleDelete(id)
}
const handlePopover = (target: HTMLButtonElement | null) => {
setAnchorEl(target)
}
return (
<div className={classes.root}>
{handleUpdate && (
<Box mr={0.5}>
<Button
variant="outlined"
color="primary"
size="small"
onClick={onClickUpdate}
>
{t('label.button.edit')}
</Button>
</Box>
)}
{handleDelete && (
<Box ml={0.5}>
<Button
variant="outlined"
color="secondary"
size="small"
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
handlePopover(event.currentTarget)
}}
>
{t('label.button.delete')}
</Button>
<ConfirmPopover
message={t('msg.confirm.delete')}
anchorEl={anchorEl}
handlePopover={handlePopover}
handleConfirm={onClickDelete}
/>
</Box>
)}
</div>
)
}
export { GridButtons }

View File

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

View File

@@ -0,0 +1,50 @@
import Button from '@material-ui/core/Button'
import Dialog, { DialogProps } from '@material-ui/core/Dialog'
import DialogActions from '@material-ui/core/DialogActions'
import DialogContent from '@material-ui/core/DialogContent'
import DialogContentText from '@material-ui/core/DialogContentText'
import DialogTitle from '@material-ui/core/DialogTitle'
import React from 'react'
import { useTranslation } from 'react-i18next'
export interface ConfirmDialogProps extends DialogProps {
title?: string
contentText?: string
handleConfirm: () => void
handleClose: () => void
}
const ConfirmDialog = (props: ConfirmDialogProps) => {
const { open, handleClose, handleConfirm, title, contentText, ...rest } =
props
const { t } = useTranslation()
return (
<Dialog
open={open}
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-description"
{...rest}
>
{title && <DialogTitle id="confirm-dialog-title">{title}</DialogTitle>}
{contentText && (
<DialogContent>
<DialogContentText id="confirm-dialog-description">
{contentText}
</DialogContentText>
</DialogContent>
)}
<DialogActions>
<Button variant="outlined" onClick={handleClose}>
{t('label.button.close')}
</Button>
<Button variant="outlined" color="secondary" onClick={handleConfirm}>
{t('label.button.confirm')}
</Button>
</DialogActions>
</Dialog>
)
}
export { ConfirmDialog }

View File

@@ -0,0 +1,67 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@material-ui/core/Button'
import Card from '@material-ui/core/Card'
import CardActions from '@material-ui/core/CardActions'
import CardContent from '@material-ui/core/CardContent'
import Popover from '@material-ui/core/Popover'
import Typography from '@material-ui/core/Typography'
export interface ConfirmPopoverProps {
message: string
handleConfirm: (event?: React.MouseEvent<HTMLButtonElement>) => void
handlePopover: (target: Element | null) => void
anchorEl: Element | null
}
const ConfirmPopover = ({
message,
handleConfirm,
handlePopover,
anchorEl,
}: ConfirmPopoverProps) => {
const open = Boolean(anchorEl)
const popId = open ? 'simple-popover' : undefined
const { t } = useTranslation()
return (
<Popover
id={popId}
open={open}
anchorEl={anchorEl}
onClose={() => {
handlePopover(null)
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
>
<Card>
<CardContent>
<Typography variant="h5">{message}</Typography>
</CardContent>
<CardActions>
<Button
variant="outlined"
onClick={() => {
handlePopover(null)
}}
>
{t('label.button.close')}
</Button>
<Button variant="outlined" color="secondary" onClick={handleConfirm}>
{t('label.button.confirm')}
</Button>
</CardActions>
</Card>
</Popover>
)
}
export { ConfirmPopover }

View File

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

View File

@@ -0,0 +1,164 @@
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import DatePicker, { ReactDatePickerProps } from 'react-datepicker'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import TextField from '@material-ui/core/TextField'
import { Box } from '@material-ui/core'
import { Controller, ControllerProps } from 'react-hook-form'
import ValidationAlert from '@components/EditForm/ValidationAlert'
import { convertStringToDate, defaultlocales } from '@libs/date'
import { ControlledFieldProps } from '.'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
display: 'flex',
'& .react-datepicker-wrapper': {
width: 'fit-content',
},
'& .react-datepicker-popper': {
zIndex: 3,
},
},
to: {
display: 'inline-flex',
alignItems: 'center',
margin: theme.spacing(2),
},
}),
)
interface CustomDataPickerProps extends Omit<ReactDatePickerProps, 'onChange'> {
name: any
label: string
contollerProps?: Omit<
ControllerProps,
'control' | 'name' | 'label' | 'render' | 'defaultValue'
>
}
interface ControlledDateRangePickerProps extends ControlledFieldProps {
startProps: CustomDataPickerProps
endProps: CustomDataPickerProps
required?: boolean
format?: string
}
const ControlledDateRangePicker = (props: ControlledDateRangePickerProps) => {
const {
getValues,
control,
formState,
startProps,
endProps,
required = false,
format = 'yyyy-MM-dd',
} = props
const classes = useStyles()
const { i18n } = useTranslation()
const [startDate, setStartDate] = useState<Date | null>(null)
const [endDate, setEndDate] = useState<Date | null>(null)
useEffect(() => {
if (getValues) {
if (getValues(startProps.name)) {
setStartDate(convertStringToDate(getValues(startProps.name)))
}
if (getValues(endProps.name)) {
setEndDate(convertStringToDate(getValues(endProps.name)))
}
}
}, [props])
return (
<>
<Box className={classes.root}>
<Controller
control={control}
name={startProps.name}
defaultValue={''}
render={({ field, fieldState }) => (
<DatePicker
customInput={
<TextField
id="outlined-basic"
label={startProps.label}
variant="outlined"
margin="dense"
required={required}
error={!!formState.errors[startProps.name]}
/>
}
selected={startDate}
onChange={(
date: Date,
event: React.SyntheticEvent<any> | undefined,
) => {
setStartDate(date)
field.onChange(date)
}}
selectsStart
startDate={startDate}
endDate={endDate}
dateFormat={format}
locale={defaultlocales[i18n.language]}
{...startProps}
/>
)}
{...startProps.contollerProps}
/>
<span className={classes.to}>~</span>
<Controller
control={control}
name={endProps.name}
defaultValue={''}
render={({ field, fieldState }) => (
<DatePicker
customInput={
<TextField
id="outlined-basic"
label={endProps.label}
variant="outlined"
margin="dense"
required={required}
error={!!formState.errors[endProps.name]}
/>
}
selected={endDate}
onChange={(
date: Date,
event: React.SyntheticEvent<any> | undefined,
) => {
setEndDate(date)
field.onChange(date)
}}
selectsEnd
startDate={startDate}
endDate={endDate}
minDate={startDate}
dateFormat={format}
locale={defaultlocales[i18n.language]}
{...endProps}
/>
)}
{...endProps.contollerProps}
/>
</Box>
{formState.errors[startProps.name] && (
<ValidationAlert
fieldError={formState.errors[startProps.name]}
label={startProps.label}
/>
)}
{formState.errors[endProps.name] && (
<ValidationAlert
fieldError={formState.errors[endProps.name]}
label={endProps.label}
/>
)}
</>
)
}
export { ControlledDateRangePicker }

View File

@@ -0,0 +1,63 @@
import React from 'react'
import { Controller } from 'react-hook-form'
import ValidationAlert from '@components/EditForm/ValidationAlert'
import RadioGroupField from '@components/RadioGroupField'
import { ControlledFieldProps } from '.'
interface ControlledRadioFieldProps extends ControlledFieldProps {
name: any
label: string
defaultValue: string
data: { idkey: string; namekey: string; data: any[] }
requried?: boolean
}
const ControlledRadioField = (props: ControlledRadioFieldProps) => {
const {
control,
formState,
name,
label,
defaultValue,
requried = false,
data,
} = props
return (
<>
<Controller
control={control}
name={name}
render={({ field, fieldState }) => (
<RadioGroupField
label={label}
required={requried}
error={!!fieldState.error}
data={data.data.map(value => {
return {
label: value[data.namekey],
value: value[data.idkey],
labelPlacement: 'end',
onChange: (
event: React.ChangeEvent<HTMLInputElement>,
checked: boolean,
) => {
field.onChange(event.target.value)
},
inputRef: field.ref,
checked: field.value === value[data.idkey] ? true : false,
}
})}
/>
)}
defaultValue={defaultValue}
rules={{ required: requried }}
/>
{formState.errors[name] && (
<ValidationAlert fieldError={formState.errors[name]} label={label} />
)}
</>
)
}
export { ControlledRadioField }

View File

@@ -0,0 +1,68 @@
import React from 'react'
import { Controller, ControllerProps } from 'react-hook-form'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Box from '@material-ui/core/Box'
import FormControlLabel, {
FormControlLabelProps,
} from '@material-ui/core/FormControlLabel'
import Switch from '@material-ui/core/Switch'
import ValidationAlert from '@components/EditForm/ValidationAlert'
import { ControlledFieldProps } from '.'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
width: '100%',
justifyContent: 'start',
border: '1px solid rgba(0, 0, 0, 0.23)',
borderRadius: theme.spacing(0.5),
padding: theme.spacing(1),
margin: theme.spacing(1, 0),
},
}),
)
interface ControlledSwitchFieldProps extends ControlledFieldProps {
label: string
name: any
contollerProps?: Omit<
ControllerProps,
'control' | 'name' | 'label' | 'render'
>
labelProps?: Omit<FormControlLabelProps, 'label' | 'control'>
}
const ControlledSwitchField = (props: ControlledSwitchFieldProps) => {
const { control, formState, label, name, contollerProps, labelProps } = props
const classes = useStyles()
return (
<Box className={classes.root}>
<FormControlLabel
label={label}
labelPlacement="start"
control={
<Controller
name={name}
control={control}
defaultValue={false}
render={({ field: { onChange, ref, value } }) => (
<Switch
inputProps={{ 'aria-label': 'secondary checkbox' }}
onChange={onChange}
inputRef={ref}
checked={value}
/>
)}
{...contollerProps}
/>
}
{...labelProps}
/>
{formState.errors[name] && (
<ValidationAlert fieldError={formState.errors[name]} label={label} />
)}
</Box>
)
}
export { ControlledSwitchField }

View File

@@ -0,0 +1,91 @@
import React from 'react'
import { Controller, ControllerProps } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import ValidationAlert from '@components/EditForm/ValidationAlert'
import TextField, { TextFieldProps } from '@material-ui/core/TextField'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import { ControlledFieldProps } from '.'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
},
content: {
display: 'flex',
alignItems: 'center',
},
}),
)
interface ControlledTextFieldProps extends ControlledFieldProps {
name: any
label: string
defaultValue: string | number
isSelect?: boolean
children?: React.ReactNode
textFieldProps?: TextFieldProps
help?: string | React.ReactNode
contollerProps?: Omit<
ControllerProps,
'control' | 'name' | 'label' | 'render' | 'defaultValue'
>
}
const ControlledTextField = (props: ControlledTextFieldProps) => {
const {
control,
formState,
name,
label,
defaultValue,
isSelect = false,
children,
textFieldProps,
help,
contollerProps,
} = props
const { t } = useTranslation()
const classes = useStyles()
return (
<div className={classes.root}>
<div className={classes.content}>
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<TextField
fullWidth
select={isSelect}
label={label}
required
variant="outlined"
margin="dense"
inputRef={field.ref}
value={field.value}
error={!!fieldState.error}
{...field}
{...textFieldProps}
>
{isSelect && children}
</TextField>
)}
defaultValue={defaultValue}
rules={{ required: true, maxLength: 100 }}
{...contollerProps}
/>
{help && help}
</div>
{formState.errors[name] && (
<ValidationAlert
fieldError={formState.errors[name]}
target={[100]}
label={label}
/>
)}
</div>
)
}
export { ControlledTextField }

View File

@@ -0,0 +1,12 @@
import { Control, FormState, UseFormGetValues } from 'react-hook-form'
export * from './ControlledDateRangePicker'
export * from './ControlledRadioField'
export * from './ControlledSwitchField'
export * from './ControlledTextField'
export interface ControlledFieldProps {
control: Control<any, object>
formState: FormState<any>
getValues?: UseFormGetValues<any>
}

View File

@@ -0,0 +1,19 @@
import React from 'react'
import { Typography } from '@material-ui/core'
import Link from '@material-ui/core/Link'
import { getCurrentDate } from '@libs/date'
const Copyright = () => {
return (
<Typography variant="body2" color="textSecondary">
{'Copyright © '}
<Link color="inherit" href="https://material-ui.com/">
Your Website
</Link>{' '}
{getCurrentDate().getFullYear()}
{'.'}
</Typography>
)
}
export default Copyright

View File

@@ -0,0 +1,110 @@
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Button, { ButtonProps } from '@material-ui/core/Button'
import Dialog, { DialogProps } from '@material-ui/core/Dialog'
import DialogActions from '@material-ui/core/DialogActions'
import DialogContent from '@material-ui/core/DialogContent'
import DialogContentText from '@material-ui/core/DialogContentText'
import DialogTitle from '@material-ui/core/DialogTitle'
import { Color } from '@material-ui/lab/Alert'
import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'
import ErrorOutlineOutlinedIcon from '@material-ui/icons/ErrorOutlineOutlined'
import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined'
import CheckCircleOutlineOutlinedIcon from '@material-ui/icons/CheckCircleOutlineOutlined'
import { Typography } from '@material-ui/core'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
icon: {
position: 'relative',
top: '0.11em',
width: theme.typography.h5.fontSize,
height: theme.typography.h5.fontSize,
},
}),
)
export interface CustomAlertPrpps extends DialogProps {
title?: string
severity?: Color
contentText?: string | string[]
handleAlert: () => void
buttonText?: string
buttonProps?: ButtonProps
}
const CustomAlert = (props: CustomAlertPrpps) => {
const {
open,
handleAlert,
title,
severity,
contentText,
buttonText,
buttonProps,
...rest
} = props
const classes = useStyles()
const { t } = useTranslation()
const icon = useCallback(() => {
return severity === 'error' ? (
<ErrorOutlineOutlinedIcon color="error" className={classes.icon} />
) : severity === 'success' ? (
<CheckCircleOutlineOutlinedIcon className={classes.icon} />
) : severity === 'warning' ? (
<ReportProblemOutlinedIcon className={classes.icon} />
) : (
<InfoOutlinedIcon className={classes.icon} />
)
}, [severity])
return (
<Dialog
open={open}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
{...rest}
>
<DialogTitle id="alert-dialog-title" disableTypography={true}>
<Typography variant="h5">
{icon()} {title || t('common.noti')}
</Typography>
</DialogTitle>
{contentText && (
<DialogContent>
{Array.isArray(contentText) ? (
contentText.map((value, index) => (
<DialogContentText
key={`dialog-${index}`}
id={`alert-dialog-description-${index}`}
>
- {value}
</DialogContentText>
))
) : (
<DialogContentText id="alert-dialog-description">
{contentText}
</DialogContentText>
)}
</DialogContent>
)}
<DialogActions>
<Button
variant="outlined"
onClick={handleAlert}
color="primary"
autoFocus
{...buttonProps}
>
{buttonText || t('label.button.confirm')}
</Button>
</DialogActions>
</Dialog>
)
}
export default CustomAlert

View File

@@ -0,0 +1,84 @@
import React from 'react'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import {
Bar,
BarChart as ReBarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import Paper from '@material-ui/core/Paper'
import Typography from '@material-ui/core/Typography'
const MAB_BAR_THICKNESS = 50
const useStyles = makeStyles((theme: Theme) =>
createStyles({
paper: {
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
padding: theme.spacing(2),
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
},
title: {
marginBottom: theme.spacing(1),
},
}),
)
interface BarChartProps {
data: any
id: string
tooltipContent?: ({ active, payload, label }) => React.ReactNode
title?: string
handleCellClick?: (data, index) => void
customxAxisTick?: (value: any, index: number) => string
}
const CustomBarChart = ({
data,
id,
tooltipContent,
title,
handleCellClick,
customxAxisTick,
}: BarChartProps) => {
const classes = useStyles()
return (
<Paper variant="outlined" className={classes.paper}>
{title && (
<Typography className={classes.title} variant="h4">
{title}
</Typography>
)}
<ResponsiveContainer width="90%" height={400}>
<ReBarChart
key={id}
data={data}
margin={{ top: 20, right: 30, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="x" tickFormatter={customxAxisTick} />
<YAxis />
<Tooltip cursor={{ fill: 'transparent' }} content={tooltipContent} />
<Bar
dataKey="y"
maxBarSize={MAB_BAR_THICKNESS}
stroke="#8884d8"
fill="#8884d8"
type="monotone"
onClick={handleCellClick}
/>
</ReBarChart>
</ResponsiveContainer>
</Paper>
)
}
export default CustomBarChart

View File

@@ -0,0 +1,84 @@
import { useTreeItemStyles } from '@components/DraggableTreeMenu/DraaggableTreeMenuItem'
import Checkbox from '@material-ui/core/Checkbox'
import Icon from '@material-ui/core/Icon'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Typography from '@material-ui/core/Typography'
import TreeItem, { TreeItemProps } from '@material-ui/lab/TreeItem'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
color: theme.palette.text.secondary,
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
},
checked: {
padding: theme.spacing(0.5),
},
}),
)
interface CustomTreeItemProps extends TreeItemProps {
node: any
isChecked?: boolean
handleChecked?: (node: any, checked: boolean) => void
}
const CustomTreeItem = (props: CustomTreeItemProps) => {
const { node, isChecked, handleChecked, ...rest } = props
const classes = useStyles()
const itemClasses = useTreeItemStyles()
const { i18n } = useTranslation()
const [checked, setChecked] = useState<boolean>(node.isChecked)
useEffect(() => {
if (node) {
setChecked(node.isChecked)
}
}, [node])
const handleLabelClick = (event: React.MouseEvent<HTMLInputElement>) => {
event.preventDefault()
handleChecked(node, !checked)
}
return (
<TreeItem
label={
<div
className={`${itemClasses.labelRoot} ${
checked ? itemClasses.selected : ''
}`}
>
{isChecked && (
<Checkbox
className={classes.checked}
checked={checked}
color="primary"
size="small"
inputProps={{ 'aria-label': 'primary checkbox' }}
/>
)}
<Icon fontSize="small" className={itemClasses.labelIcon}>
{node.icon || 'folder'}
</Icon>
<Typography variant="body2" className={itemClasses.labelText}>
{i18n.language === 'ko' ? node.korName : node.engName}
</Typography>
</div>
}
onLabelClick={handleLabelClick}
classes={{
root: classes.root,
label: itemClasses.label,
}}
{...rest}
/>
)
}
export default CustomTreeItem

View File

@@ -0,0 +1,187 @@
import {
checkedChildren,
findAllIds,
findTreeItem,
treeChecked,
treeTargetChecked,
} from '@components/DraggableTreeMenu/TreeUtils'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown'
import ArrowRightIcon from '@material-ui/icons/ArrowRight'
import TreeView, { TreeViewProps } from '@material-ui/lab/TreeView'
import produce from 'immer'
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from 'react'
import CustomTreeItem from './CustomTreeItem'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
},
}),
)
export type CustomTreeViewType = {
getTreeData: () => any[]
handleAllChecked: (checked: boolean) => void
}
interface CustomTreeViewProps {
data: any[]
isChecked?: boolean
isAllExpanded?: boolean
treeViewProps?: TreeViewProps
}
const renderTree = (
nodes: any,
isChecked: boolean,
handleChecked: (node: object, checked: boolean) => void,
) => {
return (
<CustomTreeItem
key={`item-${nodes.id}`}
nodeId={`${nodes.id}`}
node={nodes}
isChecked={isChecked}
handleChecked={handleChecked}
>
{Array.isArray(nodes.children)
? nodes.children.map((node, idx) =>
renderTree(node, isChecked, handleChecked),
)
: null}
</CustomTreeItem>
)
}
const CustomTreeView = forwardRef<CustomTreeViewType, CustomTreeViewProps>(
(props, ref) => {
const { data, isChecked = false, isAllExpanded, treeViewProps } = props
const classes = useStyles()
const [expanded, setExpanded] = useState<string[]>(null)
const [tree, setTree] = useState(data)
useEffect(() => {
if (data) {
setTree(data)
}
}, [data])
useEffect(() => {
if (isAllExpanded) {
const ids: string[] = findAllIds(tree)
setExpanded(ids)
} else {
setExpanded([])
}
}, [isAllExpanded])
const handleNodeToggle = (event: object, nodeIds: []) => {
setExpanded(nodeIds)
}
const handleChecked = (node: any, checked: boolean) => {
// 해당 노드와 자식노드들 chekced 상태 변경
const updateTree = (item: any, isChild: boolean = false) => {
return produce(item, draft => {
if (isChild || `${item.id}` === `${node.id}`) {
draft.isChecked = checked
if (draft.children) {
const arr = Array.from(draft.children)
draft.children = arr.map(i => {
return updateTree(i, true)
})
}
} else {
if (draft.children) {
const arr = Array.from(draft.children)
draft.children = arr.map(i => {
return updateTree(i, false)
})
}
}
})
}
let newTree = tree.map(item => {
return updateTree(item)
}) as any[]
if (checked) {
//checked = true 이면 부모 node checked
let findItem = { ...node }
while (true) {
const find = findTreeItem(newTree, findItem.id, 'id')
if (!find.parent) {
break
}
newTree = newTree.map(item => {
return treeTargetChecked(item, find.parent, checked)
}) as any[]
findItem = { ...find.parent }
}
} else {
//checked = false 이면 level==1 인 부모 node는 자식 node 들 중 체크가 하나라도 있으면 그냥 넘어가고 하나도 없으면 체크 해제
let findItem = { ...node }
if (findItem.level > 1) {
let level = 0
while (level !== 1) {
const find = findTreeItem(newTree, findItem.id, 'id')
level = find.parent.level
findItem = { ...find.parent }
}
const childrenCheck = checkedChildren(findItem)
if (!childrenCheck) {
newTree = newTree.map(item => {
return treeTargetChecked(item, findItem, false)
}) as any[]
}
}
}
setTree(newTree)
}
useImperativeHandle(ref, () => ({
getTreeData: () => {
return tree
},
handleAllChecked: (checked: boolean) => {
const newTree = tree.map(item => {
return treeChecked(item, checked)
})
setTree(newTree)
},
}))
return (
<TreeView
className={classes.root}
defaultCollapseIcon={<ArrowDropDownIcon />}
defaultExpandIcon={<ArrowRightIcon />}
defaultEndIcon={<div style={{ width: 24 }} />}
expanded={expanded}
onNodeToggle={handleNodeToggle}
{...treeViewProps}
>
{tree && tree.map(item => renderTree(item, isChecked, handleChecked))}
</TreeView>
)
},
)
export default CustomTreeView

View File

@@ -0,0 +1,74 @@
import { Typography } from '@material-ui/core'
import Dialog, { DialogProps } from '@material-ui/core/Dialog'
import DialogActions, {
DialogActionsProps,
} from '@material-ui/core/DialogActions'
import DialogContent from '@material-ui/core/DialogContent'
import DialogTitle from '@material-ui/core/DialogTitle'
import IconButton from '@material-ui/core/IconButton'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import CloseIcon from '@material-ui/icons/Close'
import React from 'react'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
close: {
position: 'absolute',
color: theme.palette.grey[500],
right: theme.spacing(1),
top: theme.spacing(1),
},
}),
)
/**
* 기존의 페이지를 팝업창으로 호출할 경우 사용
*/
export interface PopupProps {
handlePopup?: (data: any) => void
}
export interface DialogPopupProps extends DialogProps {
id: string
children: React.ReactNode
handleClose: () => void
title?: string
action?: {
props: DialogActionsProps
children: React.ReactNode
}
}
const DialogPopup = (props: DialogPopupProps) => {
const { id, children, handleClose, title, action, ...rest } = props
const classes = useStyles()
return (
<Dialog
aria-labelledby={id}
fullWidth
maxWidth="md"
onClose={handleClose}
{...rest}
>
<DialogTitle disableTypography id="dialog-title">
<Typography variant="h3"> {title || 'Popup'}</Typography>
{handleClose && (
<IconButton
className={classes.close}
aria-label="close"
onClick={handleClose}
>
<CloseIcon />
</IconButton>
)}
</DialogTitle>
<DialogContent dividers>{children}</DialogContent>
{action && (
<DialogActions {...action.props}>{action.children}</DialogActions>
)}
</Dialog>
)
}
export default DialogPopup

View File

@@ -0,0 +1,55 @@
import Grid, { GridProps } from '@material-ui/core/Grid'
import Paper from '@material-ui/core/Paper'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Typography from '@material-ui/core/Typography'
import React from 'react'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
label: {
padding: theme.spacing(1),
textAlign: 'center',
backgroundColor: theme.palette.background.default,
},
text: {
padding: theme.spacing(1),
textAlign: 'left',
},
}),
)
interface DisableTextFieldProps {
label: string
value: string | number | React.ReactNode
labelProps?: GridProps
valueProps?: GridProps
}
const DisableTextField = ({
label,
value,
labelProps,
valueProps,
}: DisableTextFieldProps) => {
const classes = useStyles()
return (
<Grid container spacing={1}>
<Grid item xs={4} {...labelProps}>
<Paper className={classes.label}>
<Typography variant="body1">{label}</Typography>
</Paper>
</Grid>
<Grid item xs={8} {...valueProps}>
<Paper className={classes.text}>
{typeof value === 'string' || typeof value === 'number' ? (
<Typography variant="body1">{value}</Typography>
) : (
value
)}
</Paper>
</Grid>
</Grid>
)
}
export default DisableTextField

View File

@@ -0,0 +1,78 @@
import { ItemId, TreeItem } from '@atlaskit/tree'
import { IMenuTree } from '@service'
import produce from 'immer'
export type TreeItemType = TreeItem & {
parentId?: number
}
export default class TreeBuilder {
rootId: ItemId
items: Record<ItemId, TreeItemType>
constructor(rootId: ItemId, data?: IMenuTree) {
const rootItem = this._createItem(`${rootId}`, data)
this.rootId = rootItem.id
this.items = {
[rootItem.id]: rootItem,
}
}
withLeaf(item: IMenuTree) {
const leafItem = this._createItem(`${this.rootId}-${item.menuId}`, item)
this._addItemToRoot(leafItem.id)
this.items[leafItem.id] = leafItem
return this
}
withSubTree(tree: TreeBuilder) {
const subTree = tree.build()
this._addItemToRoot(`${this.rootId}-${subTree.rootId}`)
Object.keys(subTree.items).forEach(itemId => {
const finalId = `${this.rootId}-${itemId}`
this.items[finalId] = {
...subTree.items[itemId],
id: finalId,
children: subTree.items[itemId].children.map(
i => `${this.rootId}-${i}`,
),
}
})
return this
}
build() {
return {
rootId: this.rootId,
items: this.items,
}
}
_addItemToRoot(id: string) {
const rootItem = this.items[this.rootId]
rootItem.children.push(id)
rootItem.isExpanded = true
rootItem.hasChildren = true
}
_createItem = (id: string, data?: IMenuTree) => {
data = produce(data, draft => {
if (draft) {
draft.children = []
}
})
return {
id: `${id}`,
children: [],
hasChildren: false,
isExpanded: false,
isChildrenLoading: false,
data,
parentId: data?.parentId,
}
}
}

View File

@@ -0,0 +1,189 @@
import React, { createRef, useEffect, useState } from 'react'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import { ItemId, TreeItem } from '@atlaskit/tree'
import IconButton from '@material-ui/core/IconButton'
import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown'
import ArrowRightIcon from '@material-ui/icons/ArrowRight'
import Icon from '@material-ui/core/Icon'
import Typography from '@material-ui/core/Typography'
import { useRecoilState } from 'recoil'
import { treeChangeNameAtom, draggableTreeSelectedAtom } from '@stores'
import TextField from '@material-ui/core/TextField'
import { ClassNameMap } from '@material-ui/styles'
export const useTreeItemStyles = makeStyles((theme: Theme) =>
createStyles({
labelRoot: {
display: 'flex',
alignItems: 'center',
padding: theme.spacing(0.5, 0),
},
label: {
display: 'flex',
color: theme.palette.text.secondary,
},
selected: {
color: '#1a73e8',
},
labelIcon: {
marginRight: theme.spacing(1),
},
labelText: {
fontWeight: 'inherit',
flexGrow: 1,
textAlign: 'initial',
},
bull: {
width: '1em',
height: '1em',
fontSize: '1.5rem',
marginRight: theme.spacing(1),
paddingLeft: theme.spacing(1),
},
}),
)
const getIcon = (
item: TreeItem,
onExpand: (itemId: ItemId) => void,
onCollapse: (itemId: ItemId) => void,
classes: ClassNameMap,
) => {
if (item.children && item.children.length > 0) {
return item.isExpanded ? (
<IconButton
size="small"
onClick={() => onCollapse(item.id)}
aria-label="collapse"
>
<ArrowDropDownIcon />
</IconButton>
) : (
<IconButton
size="small"
onClick={() => onExpand(item.id)}
aria-label="expand"
>
<ArrowRightIcon />
</IconButton>
)
}
return (
<IconButton className={classes.bull} size="small">
&bull;
</IconButton>
)
}
const setLabel = (
item: TreeItem,
classes: ClassNameMap,
handleClick: (event: React.MouseEvent<HTMLDivElement>) => void,
handleKeyPress: (event: React.KeyboardEvent<HTMLInputElement>) => void,
handleBlur: () => void,
changed?: boolean,
selected?: boolean,
inputRef?: React.RefObject<HTMLInputElement>,
) => {
if (changed) {
return (
<TextField
size="small"
id="tree-name"
inputRef={inputRef}
margin="dense"
defaultValue={item.data?.name}
onKeyPress={handleKeyPress}
onBlur={handleBlur}
autoFocus
/>
)
}
return (
<div
onClick={handleClick}
className={`${classes.label} ${selected ? classes.selected : ''}`}
>
<Icon fontSize="small" className={classes.labelIcon}>
{item.data?.icon || 'folder'}
</Icon>
<Typography variant="body2" className={classes.labelText}>
{item.data ? item.data.name : ''}
</Typography>
</div>
)
}
export interface DraaggableTreeMenuItemProps {
item: TreeItem
onExpand: (itemId: ItemId) => void
onCollapse: (itemId: ItemId) => void
selected?: boolean
}
const DraaggableTreeMenuItem = (props: DraaggableTreeMenuItemProps) => {
const classes = useTreeItemStyles()
const { item, onExpand, onCollapse, selected } = props
const [treeSelected, setTreeSelected] = useRecoilState(
draggableTreeSelectedAtom,
)
const [treeChangeName, setTreeChangeName] = useRecoilState(treeChangeNameAtom)
const [changed, setChanged] = useState<boolean>(false)
const nameRef = createRef<HTMLInputElement>()
useEffect(() => {
if (
treeChangeName.state === 'change' &&
item.data?.menuId === treeSelected?.menuId
) {
setChanged(true)
return
}
setChanged(false)
}, [treeSelected, item, treeChangeName])
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault()
setTreeSelected(item.data)
}
const handleKeyPress = e => {
if (e.key === 'Enter') {
setTreeChangeName({
state: 'complete',
id: item.data?.menuId,
name: nameRef.current?.value,
})
}
}
const handleBlur = () => {
setTreeChangeName({
state: 'none',
id: null,
name: null,
})
}
return (
<div className={`${classes.labelRoot}`}>
{getIcon(item, onExpand, onCollapse, classes)}
{setLabel(
item,
classes,
handleClick,
handleKeyPress,
handleBlur,
changed,
selected,
nameRef,
)}
</div>
)
}
export default DraaggableTreeMenuItem

View File

@@ -0,0 +1,59 @@
import React from 'react'
import Button from '@material-ui/core/Button'
import ButtonGroup from '@material-ui/core/ButtonGroup'
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'
import ExpandLessOutlinedIcon from '@material-ui/icons/ExpandLessOutlined'
import ExpandMoreOutlinedIcon from '@material-ui/icons/ExpandMoreOutlined'
import CheckBoxOutlineBlankOutlinedIcon from '@material-ui/icons/CheckBoxOutlineBlankOutlined'
import { useTranslation } from 'react-i18next'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
'& .MuiButton-containedSizeSmall': {
padding: '4px 6px',
fontSize: '0.8rem',
},
whiteSpace: 'nowrap',
},
}),
)
export interface TreeBelowButtonsProps {
handleExpand: (event: React.MouseEvent<HTMLButtonElement>) => void
handleCollapse: (event: React.MouseEvent<HTMLButtonElement>) => void
handleDeselect?: (event: React.MouseEvent<HTMLButtonElement>) => void
}
const TreeSubButtons = (props: TreeBelowButtonsProps) => {
const { handleExpand, handleCollapse, handleDeselect } = props
const classes = useStyles()
const { t } = useTranslation()
return (
<>
<ButtonGroup
className={classes.root}
size="small"
aria-label="menu tree buttons"
variant="contained"
>
<Button onClick={handleExpand}>
<ExpandMoreOutlinedIcon fontSize="small" />
{t('menu.all_expand')}
</Button>
<Button onClick={handleCollapse}>
<ExpandLessOutlinedIcon fontSize="small" />
{t('menu.all_collapse')}
</Button>
{handleDeselect && (
<Button onClick={handleDeselect}>
<CheckBoxOutlineBlankOutlinedIcon fontSize="small" />
{t('label.button.deselect')}
</Button>
)}
</ButtonGroup>
</>
)
}
export default TreeSubButtons

View File

@@ -0,0 +1,249 @@
import { TreeData, TreeItem } from '@atlaskit/tree'
import { IMenuTree } from '@service'
import produce from 'immer'
import DraggableTreeBuilder from './DraaggableTreeBuilder'
/**
* hierarchy json data -> atlaskit flat tree data
*
* @param data
* @returns
*/
export const convertJsonToTreeData = (data: IMenuTree[]) => {
const createTree = (item: any, builder: DraggableTreeBuilder) => {
if (item.children) {
let sub = new DraggableTreeBuilder(item.menuId, item)
item.children.map(i => createTree(i, sub))
builder.withSubTree(sub)
} else {
builder.withLeaf(item)
}
}
let root = new DraggableTreeBuilder(0)
data?.map(item => {
createTree(item, root)
})
return root.build()
}
/**
* atlaskit flat tree data -> hierarchy json data
*
* @param tree
* @returns
*/
export const convertTreeDataToJson = (tree: TreeData) => {
let newTreeItem: TreeItem[] = []
const arrTree = Object.values(tree.items)
const root = arrTree.shift()
root.children.map((itemId, index) => {
const data = arrTree.splice(
arrTree.findIndex(item => item.id === itemId),
1,
)
data.map(item => {
item.data = produce(item.data, draft => {
draft.sortSeq = index + 1
draft.parentId = null
})
newTreeItem.push(item)
})
})
const convert = (target: TreeItem[], source: TreeItem[]) => {
while (source.length > 0) {
const data = source.shift()
if (data.hasChildren) {
target.push(
produce(data, draft => {
draft.hasChildren = false
}),
)
}
const idx = target.findIndex(item => item.children.includes(data.id))
if (idx > -1) {
const parent = produce(target[idx].data as IMenuTree, draft => {
const childIdx = draft.children.findIndex(
i => i.menuId === data.data.menuId,
)
const child = produce(data.data as IMenuTree, childDraft => {
if (childIdx === -1) {
childDraft.sortSeq = draft.children.length + 1
}
childDraft.parentId = draft.menuId
})
if (childIdx > -1) {
draft.children[childIdx] = child
} else {
draft.children.push(child)
}
})
target[idx] = produce(target[idx], draft => {
draft.data = parent
})
}
}
return target
}
let target = newTreeItem.slice()
let source = arrTree.slice()
while (true) {
newTreeItem = convert(target, source).slice()
if (root.children.length === newTreeItem.length) {
break
}
target = newTreeItem.filter(item => root.children.includes(item.id))
source = newTreeItem.filter(item => !root.children.includes(item.id))
}
const newData: IMenuTree[] = []
newTreeItem.map(treeItem => {
newData.push(Object.assign(treeItem.data))
})
return newData
}
export interface IFindTree {
item: any
parent: any
}
/**
* hierarchy json data에서 조건에 맞는 데이터 찾기
*
* @param arr 원본 json array (any[])
* @param value 찾고자 하는 데이터 (number or string)
* @param key object인 경우 데이터 key (string)
* @returns
*/
export const findTreeItem = (
arr: any[],
value: number | string,
key?: string,
): IFindTree => {
let target
let parent
let findKey = key || 'index'
const findAllItems = (item: any, parentItem?: any) => {
if (item[findKey] === value) {
target = item
parent = parentItem
return
}
if (item.children) {
item.children.map((v, k) => {
return findAllItems(v, item)
})
}
}
arr.map(item => {
findAllItems(item)
})
return {
item: target,
parent,
}
}
/**
* hierarchy json data에서 모든 id값 찾기
*
* @param arr 원본 json array (any[])
* @param findKey id의 key 값 = default 'id' (string)
* @returns string[]
*/
export const findAllIds = (arr: any[], findKey?: string): string[] => {
const ids = []
const key = findKey || 'id'
const findAll = (item: any) => {
ids.push(`${item[key]}`)
if (item.children) {
item.children.map(i => findAll(i))
}
}
arr.map(item => {
findAll(item)
})
return ids
}
/**
* hierarchy json data에서 해당하는 데이터 checked or unchecked
*
* @param node
* @param target
* @param checked
* @returns
*/
export const treeTargetChecked = (node: any, target: any, checked: boolean) => {
return produce(node, draft => {
if (`${draft.id}` === `${target.id}`) {
draft.isChecked = checked
} else {
if (draft.children) {
const arr = Array.from(draft.children)
draft.children = arr.map(i => {
return treeTargetChecked(i, target, checked)
})
}
}
})
}
/**
* 해당 노드 자식 데이터 모두 checked
*
* @param node
* @returns
*/
export const checkedChildren = (node: any) => {
for (const iterator of node.children) {
if (iterator.isChecked) {
return true
}
}
return false
}
/**
* tree data all checked or unchecked
*
* @param node
* @param checked
* @returns
*/
export const treeChecked = (node: any, checked: boolean) => {
return produce(node, draft => {
draft.isChecked = checked
if (draft.children) {
const arr = Array.from(draft.children)
draft.children = arr.map(i => {
return treeChecked(i, checked)
})
}
})
}

View File

@@ -0,0 +1,153 @@
import React, { useEffect, useState } from 'react'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Tree, {
ItemId,
moveItemOnTree,
mutateTree,
RenderItemParams,
TreeData,
TreeDestinationPosition,
TreeSourcePosition,
} from '@atlaskit/tree'
import { convertTreeDataToJson, convertJsonToTreeData } from './TreeUtils'
import { IMenuTree } from '@service'
import DraaggableTreeMenuItem from './DraaggableTreeMenuItem'
import { useRecoilState, useRecoilValue } from 'recoil'
import { draggableTreeExpandedAtom, draggableTreeSelectedAtom } from '@stores'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
marginBottom: theme.spacing(1),
},
item: {
color: theme.palette.text.secondary,
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
},
selected: {
backgroundColor: `#e8f0fe`,
color: '#1a73e8',
},
}),
)
const PADDING_PER_LEVEL = 40
export interface DraggableTreeMenuProps {
data: IMenuTree[]
handleTreeDnD: (tree: IMenuTree[]) => void
}
function DraggableTreeMenu(props: DraggableTreeMenuProps) {
const classes = useStyles()
const { data, handleTreeDnD } = props
const treeSelected = useRecoilValue(draggableTreeSelectedAtom)
const [treeExpanded, setTreeExpanded] = useRecoilState(
draggableTreeExpandedAtom,
)
const [tree, setTree] = useState<TreeData>(null)
useEffect(() => {
setTreeExpanded('collapse')
}, [])
useEffect(() => {
if (data) {
setTree(convertJsonToTreeData(data))
}
}, [data])
useEffect(() => {
if (treeExpanded === 'none') {
return
}
if (!tree) {
return
}
const expanded = treeExpanded === 'expand'
let treeData = tree
for (const key in tree.items) {
if (Object.prototype.hasOwnProperty.call(tree.items, key)) {
treeData = mutateTree(treeData, key, { isExpanded: expanded })
}
}
setTree(treeData)
}, [treeExpanded])
const renderItem = ({
item,
onExpand,
onCollapse,
provided,
}: RenderItemParams) => {
const selected: boolean = item.data?.menuId === treeSelected?.menuId
return (
<div
className={`${classes.item} ${selected ? classes.selected : ''}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<DraaggableTreeMenuItem
item={item}
onExpand={onExpand}
onCollapse={onCollapse}
selected={selected}
/>
</div>
)
}
const onExpand = (itemId: ItemId) => {
setTreeExpanded('none')
setTree(mutateTree(tree, itemId, { isExpanded: true }))
}
const onCollapse = (itemId: ItemId) => {
setTreeExpanded('none')
setTree(mutateTree(tree, itemId, { isExpanded: false }))
}
const onDragEnd = async (
source: TreeSourcePosition,
destination?: TreeDestinationPosition,
) => {
if (!destination) {
return
}
const newTree = moveItemOnTree(tree, source, destination)
const convert = await convertTreeDataToJson(newTree)
handleTreeDnD(convert)
setTree(newTree)
}
return (
<div className={classes.root}>
{tree && (
<Tree
tree={tree}
renderItem={renderItem}
onExpand={onExpand}
onCollapse={onCollapse}
onDragEnd={onDragEnd}
offsetPerLevel={PADDING_PER_LEVEL}
isDragEnabled
isNestingEnabled
/>
)}
</div>
)
}
export default DraggableTreeMenu

View File

@@ -0,0 +1,539 @@
import { DetailButtons } from '@components/Buttons'
import DialogPopup from '@components/DialogPopup'
import Button from '@material-ui/core/Button'
import Card from '@material-ui/core/Card'
import CardContent from '@material-ui/core/CardContent'
import CardHeader from '@material-ui/core/CardHeader'
import Divider from '@material-ui/core/Divider'
import FormControlLabel from '@material-ui/core/FormControlLabel'
import FormGroup from '@material-ui/core/FormGroup'
import FormHelperText from '@material-ui/core/FormHelperText'
import Paper from '@material-ui/core/Paper'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import TextField from '@material-ui/core/TextField'
import ToggleButton from '@material-ui/lab/ToggleButton'
import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup'
import Board from '@pages/board'
import Content from '@pages/content'
import { MenuFormContext } from '@pages/menu'
import { ICode, IMenuInfoForm } from '@service'
import produce from 'immer'
import React, { useContext, useEffect, useState } from 'react'
import { Controller, FormProvider, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import ValidationAlert from './ValidationAlert'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
},
content: {
padding: theme.spacing(1, 2),
},
buttons: {
marginBottom: theme.spacing(1),
'& .MuiToggleButtonGroup-grouped': {
lineHeight: 1,
},
},
searchButton: {
whiteSpace: 'nowrap',
},
search: {
display: 'flex',
alignItems: 'center',
boxShadow: theme.shadows[0],
},
select: {
marginLeft: theme.spacing(1),
flex: 1,
},
verticalDivider: {
height: 28,
margin: 4,
},
}),
)
export interface MenuEditFormProps {
handleSave: (formData: IMenuInfoForm) => void
menuTypes?: ICode[]
}
interface IConnectId {
code?: number
name?: string
error?: boolean
}
const MenuEditForm = (props: MenuEditFormProps) => {
const { handleSave, menuTypes } = props
const classes = useStyles()
const { t } = useTranslation()
const { menuFormData, setMenuFormDataHandler } = useContext(MenuFormContext)
//form hook
const methods = useForm<IMenuInfoForm>()
const {
register,
formState: { errors },
control,
handleSubmit,
setValue,
reset,
} = methods
const [blankState, setBlankState] = useState<boolean>(false)
const [menuTypeState, setMenuTypeState] = useState<string>(
menuTypes[0]?.codeId,
)
useEffect(() => {
if (errors) {
console.log(errors)
}
}, [errors])
const [connectIdState, setConnectIdState] = useState<IConnectId>({})
const [dialogOpen, setDialogOpen] = useState<boolean>(false)
useEffect(() => {
if (menuFormData) {
reset(
produce(menuFormData, draft => {
if (draft) {
draft.menuKorName = draft?.menuKorName || ''
draft.menuEngName = draft?.menuEngName || ''
draft.urlPath = draft?.urlPath || ''
draft.subName = draft?.subName || ''
draft.icon = draft?.icon || ''
draft.description = draft?.description || ''
}
}),
)
setMenuTypeState(menuFormData?.menuType || 'empty')
setBlankState(
menuFormData?.isBlank === null ? false : menuFormData?.isBlank,
)
setConnectIdState({
...connectIdState,
code: menuFormData?.connectId,
name: menuFormData?.connectName,
})
}
}, [menuFormData])
const handleLinkType = (
event: React.MouseEvent<HTMLElement>,
newValue: boolean | null,
) => {
if (newValue === null) return
setBlankState(newValue)
}
const handleMenuType = (
event: React.MouseEvent<HTMLElement>,
newValue: string | null,
) => {
if (newValue === null) return
const formOptions = {
shouldDirty: true,
shouldValidate: false,
}
setValue('connectId', null, formOptions)
setValue('urlPath', '', formOptions)
setConnectIdState({
code: null,
name: '',
error: false,
})
setMenuTypeState(newValue)
}
const handleDialogOpen = () => {
setDialogOpen(true)
}
const handleDialogClose = () => {
setDialogOpen(false)
}
const handlePopup = (data: any) => {
if (data) {
let codeKey = 'contentNo'
let nameKey = 'contentName'
if (menuTypeState === 'board') {
codeKey = 'boardNo'
nameKey = 'boardName'
}
setValue('connectId', data[codeKey], {
shouldDirty: true,
shouldValidate: true,
})
setConnectIdState({
code: data[codeKey],
name: data[nameKey],
error: false,
})
}
handleDialogClose()
}
const handleSaveBefore = (formData: IMenuInfoForm) => {
console.log('before ', formData)
formData = produce(formData, draft => {
draft.menuType = menuTypeState
draft.menuTypeName = menuTypes.find(
item => item.codeId === menuTypeState,
).codeName
draft.isBlank = blankState
})
handleSave(formData)
}
return (
<FormProvider {...methods}>
<form>
<Card className={classes.root}>
<CardHeader title={t('menu.info_title')} />
<Divider />
<CardContent className={classes.content}>
<TextField
fullWidth
label={t('menu.no')}
name="menuId"
variant="outlined"
value={menuFormData ? menuFormData.menuId : ''}
disabled
margin="dense"
/>
<Controller
name="menuKorName"
control={control}
render={({ field, fieldState }) => (
<TextField
fullWidth
label={t('menu.name')}
required
variant="outlined"
margin="dense"
inputRef={field.ref}
value={field.value}
error={!!fieldState.error}
{...field}
/>
)}
defaultValue={''}
rules={{ required: true, maxLength: 100 }}
/>
{errors.menuKorName && (
<ValidationAlert
fieldError={errors.menuKorName}
target={[100]}
label={t('menu.name')}
/>
)}
<Controller
name="menuEngName"
control={control}
render={({ field, fieldState }) => (
<TextField
fullWidth
label={t('menu.eng_name')}
required
variant="outlined"
margin="dense"
inputRef={field.ref}
value={field.value}
error={!!fieldState.error}
{...field}
/>
)}
defaultValue={''}
rules={{ required: true, maxLength: 200 }}
/>
{errors.menuEngName && (
<ValidationAlert
fieldError={errors.menuEngName}
target={[100]}
label={t('menu.eng_name')}
/>
)}
<FormHelperText>{t('menu.type')}</FormHelperText>
<ToggleButtonGroup
aria-label="menu type button group"
className={classes.buttons}
value={menuTypeState}
onChange={handleMenuType}
exclusive
>
{menuTypes?.map(item => (
<ToggleButton
key={`menuType-${item.codeId}`}
value={item.codeId}
>
{item.codeName}
</ToggleButton>
))}
</ToggleButtonGroup>
{(menuTypeState === 'inside' || menuTypeState === 'outside') && (
<>
<Controller
name="urlPath"
control={control}
render={({ field, fieldState }) => (
<TextField
fullWidth
label={t('menu.url_path')}
required
variant="outlined"
margin="dense"
inputRef={field.ref}
value={field.value}
error={!!fieldState.error}
{...field}
/>
)}
defaultValue={''}
rules={{ required: true, maxLength: 200 }}
/>
{menuTypeState === 'outside' && (
<FormHelperText>{t('menu.outside_link_help')}</FormHelperText>
)}
{errors.urlPath && (
<ValidationAlert
fieldError={errors.urlPath}
target={[200]}
label={t('menu.url_path')}
/>
)}
</>
)}
{(menuTypeState === 'contents' || menuTypeState === 'board') && (
<>
<Paper component="div" className={classes.search}>
<TextField
variant="outlined"
fullWidth
margin="dense"
label={`${
menuTypes.find(item => item.codeId === menuTypeState)
.codeName
} ${t('common.select')}`}
error={!!connectIdState.error}
value={connectIdState.name || ''}
required
disabled
/>
<input
name="connectId"
type="text"
hidden
value={connectIdState.code || ''}
{...register('connectId', { required: true })}
/>
<Divider
className={classes.verticalDivider}
orientation="vertical"
/>
<Button
className={classes.searchButton}
variant="contained"
onClick={handleDialogOpen}
>{`${
menuTypes.find(item => item.codeId === menuTypeState)
.codeName
} ${t('label.button.find')}`}</Button>
<DialogPopup
id="find-dialog"
children={
menuTypeState === 'contents' ? (
<Content handlePopup={handlePopup} />
) : (
<Board handlePopup={handlePopup} />
)
}
handleClose={handleDialogClose}
open={dialogOpen}
title={`${
menuTypes.find(item => item.codeId === menuTypeState)
.codeName
} ${t('label.button.find')}`}
/>
</Paper>
{errors.connectId && (
<ValidationAlert
fieldError={errors.connectId}
label={`${
menuTypes.find(item => item.codeId === menuTypeState)
.codeName
} ${t('common.select')}`}
/>
)}
</>
)}
{menuTypeState !== 'empty' && (
<>
<FormHelperText>{t('menu.connect_type')}</FormHelperText>
<ToggleButtonGroup
exclusive
aria-label="link type button group"
className={classes.buttons}
value={blankState}
onChange={handleLinkType}
>
<ToggleButton value={false} aria-label={t('menu.self')}>
{t('menu.self')}
</ToggleButton>
<ToggleButton value={true} aria-label={t('menu.blank')}>
{t('menu.blank')}
</ToggleButton>
</ToggleButtonGroup>
</>
)}
<Controller
name="subName"
control={control}
render={({ field, fieldState }) => (
<TextField
fullWidth
label={t('menu.sub_name')}
variant="outlined"
margin="dense"
inputRef={field.ref}
value={field.value}
error={!!fieldState.error}
{...field}
/>
)}
defaultValue={''}
rules={{ maxLength: 200 }}
/>
{errors.subName && (
<ValidationAlert fieldError={errors.subName} target={[200]} />
)}
<Controller
name="icon"
control={control}
render={({ field, fieldState }) => (
<TextField
fullWidth
label={t('menu.icon')}
variant="outlined"
margin="dense"
inputRef={field.ref}
value={field.value}
error={!!fieldState.error}
{...field}
/>
)}
defaultValue={''}
rules={{ maxLength: 100 }}
/>
{errors.icon && (
<ValidationAlert fieldError={errors.icon} target={[100]} />
)}
<Controller
name="description"
control={control}
render={({ field, fieldState }) => (
<TextField
fullWidth
label={t('menu.description')}
variant="outlined"
margin="dense"
multiline={true}
maxRows={3}
minRows={2}
inputRef={field.ref}
value={field.value}
error={!!fieldState.error}
{...field}
/>
)}
defaultValue={''}
rules={{ maxLength: 500 }}
/>
{errors.description && (
<ValidationAlert fieldError={errors.description} target={[500]} />
)}
<FormGroup row>
<FormControlLabel
control={
<Controller
name="isUse"
render={({ field: { onChange, ref, value } }) => (
<Switch
inputProps={{ 'aria-label': 'secondary checkbox' }}
onChange={onChange}
inputRef={ref}
checked={value}
/>
)}
control={control}
defaultValue={
typeof menuFormData?.isUse !== 'undefined'
? menuFormData?.isUse
: true
}
/>
}
label={t('common.use_at')}
labelPlacement="start"
/>
<FormControlLabel
control={
<Controller
name="isShow"
render={({ field: { onChange, ref, value } }) => (
<Switch
inputProps={{ 'aria-label': 'secondary checkbox' }}
onChange={onChange}
inputRef={ref}
checked={value}
/>
)}
control={control}
defaultValue={
typeof menuFormData?.isShow !== 'undefined'
? menuFormData?.isShow
: true
}
/>
}
label={t('menu.show_at')}
labelPlacement="start"
/>
</FormGroup>
</CardContent>
</Card>
</form>
<DetailButtons handleSave={handleSubmit(handleSaveBefore)} />
</FormProvider>
)
}
export { MenuEditForm }

View File

@@ -0,0 +1,93 @@
import React, { useEffect, useState } from 'react'
import { FieldError } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import Alert, { AlertProps } from '@material-ui/lab/Alert'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import { format } from '@utils'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
padding: `0px ${theme.spacing(1)}px`,
},
}),
)
export interface ValidaltionAlertProps extends AlertProps {
message?: string
fieldError?: FieldError
target?: any[]
label?: string
}
const validMessages = {
required: {
code: 'valid.required', // 값은 필수입니다.
isFormat: false,
},
min: {
code: 'valid.between.format', // {0} ~ {1} 사이의 값을 입력해주세요.
isFormat: true,
},
max: {
code: 'valid.between.format', // {0} ~ {1} 사이의 값을 입력해주세요.
isFormat: true,
},
maxLength: {
code: 'valid.maxlength.format', // {0}자 이하로 입력해주세요.
isFormat: true,
},
minLength: {
code: 'valid.minlength.format', // {0}자 이상으로 입력해주세요.
isFormat: true,
},
valueAsNumber: {
code: 'valid.valueAsNumber', // 숫자만 입력가능합니다.
isFormat: false,
},
valueAsDate: {
code: 'valid.valueAsDate', // 날짜 형식으로 입력해주세요.
isFormat: false,
},
}
const ValidationAlert = (props: ValidaltionAlertProps) => {
const { message, fieldError, target, label, ...rest } = props
const classes = useStyles()
const { t } = useTranslation()
const [validMessage, setValidMessage] = useState<string>('')
useEffect(() => {
if (message) {
setValidMessage(message)
return
}
if (fieldError.message) {
setValidMessage(fieldError.message)
return
}
const valid = validMessages[fieldError.type]
if (valid.isFormat) {
setValidMessage(format(t(valid.code), target))
return
}
setValidMessage(`${label} ${t(valid.code)}`)
}, [message, fieldError])
return (
<Alert
className={classes.root}
severity="error"
variant="outlined"
{...rest}
>
{validMessage}
</Alert>
)
}
export default ValidationAlert

View File

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

View File

@@ -0,0 +1,71 @@
import Loader from '@components/Loader'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import React, { useEffect, useRef, useState } from 'react'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
paddingTop: theme.spacing(1),
'& .ck-editor__editable_inline': {
minHeight: '200px',
},
},
}),
)
export interface IEditor {
contents: string
setContents: (data: string) => void
}
const Editor = (props: IEditor) => {
const { contents, setContents } = props
const classes = useStyles()
const editorRef = useRef<any>()
const [editorLoaded, setEditorLoaded] = useState<boolean>(false)
const { CKEditor, ClassicEditor } = editorRef.current || {}
useEffect(() => {
editorRef.current = {
CKEditor: require('@ckeditor/ckeditor5-react').CKEditor,
ClassicEditor: require('@ckeditor/ckeditor5-build-classic'),
}
setEditorLoaded(true)
}, [])
return (
<>
{editorLoaded ? (
<div className={classes.root}>
<CKEditor
editor={ClassicEditor}
data={contents}
config={{
ckfinder: {
uploadUrl: `/api/editor`,
},
}}
onReady={(editor: any) => {
console.info('editor is ready to use', editor)
}}
onChange={(event: any, editor: any) => {
const chanagedata = editor.getData()
setContents(chanagedata)
}}
onBlur={(event: any, editor: any) => {
console.info('Blur.', editor)
}}
onFocus={(event: any, editor: any) => {
console.info('Focus.', editor)
}}
/>
</div>
) : (
<Loader />
)}
</>
)
}
export default Editor

View File

@@ -0,0 +1,103 @@
import React, { useCallback } from 'react'
import { useRouter } from 'next/router'
import Typography from '@material-ui/core/Typography'
import Breadcrumbs from '@material-ui/core/Breadcrumbs'
import Link from '@material-ui/core/Link'
import { Theme, makeStyles } from '@material-ui/core/styles'
import { currentMenuStateAtom, flatMenusSelect } from '@stores'
import { useRecoilValue } from 'recoil'
import { useTranslation } from 'react-i18next'
const useStyles = makeStyles((theme: Theme) => ({
root: {
// marginBottom: theme.spacing(1),
},
}))
const Bread: React.FC = () => {
const classes = useStyles()
const router = useRouter()
const flatMenus = useRecoilValue(flatMenusSelect)
const current = useRecoilValue(currentMenuStateAtom)
const { i18n } = useTranslation()
const hierarchy = useCallback(() => {
if (!current) {
return
}
if (current?.level === 1) {
return (
<Typography color="textPrimary">
{i18n.language === 'ko' ? current?.korName : current?.engName}
</Typography>
)
}
let trees = []
const arr = flatMenus.slice(
0,
flatMenus.findIndex(item => item.id === current.id) + 1,
)
trees.push(current)
arr.reverse().some(item => {
if (item.level < current.level) {
trees.push(item)
}
if (item.level === 1) {
return true
}
})
let nodes = trees.reverse().map(item =>
item.id === current.id ? (
<Typography key={current.id} color="textPrimary">
{i18n.language === 'ko' ? current?.korName : current?.engName}
</Typography>
) : (
<Link
key={`brean-link-${item.id}`}
color="inherit"
href="/getting-started/installation/"
onClick={(event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
handleClick(event, item.urlPath)
}}
>
{i18n.language === 'ko' ? item.korName : item.engName}
</Link>
),
)
return nodes
}, [current])
const handleClick = (
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
url: string,
) => {
event.preventDefault()
if (url) {
router.push(url)
}
}
return (
<div className={classes.root}>
<Breadcrumbs separator="" aria-label="breadcrumb">
<Link
color="inherit"
href="/"
onClick={(event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
handleClick(event, '/')
}}
>
Home
</Link>
{hierarchy()}
</Breadcrumbs>
</div>
)
}
export default Bread

View File

@@ -0,0 +1,25 @@
import React from 'react'
import Typography from '@material-ui/core/Typography'
import Container from '@material-ui/core/Container'
import { makeStyles, Theme } from '@material-ui/core/styles'
import Copyright from '@components/Copyright'
const useStyles = makeStyles((theme: Theme) => ({
footer: {
padding: theme.spacing(2),
marginTop: 'auto',
backgroundColor: theme.palette.background.default,
},
}))
const Footer: React.FC = () => {
const classes = useStyles()
return (
<Container component="footer" className={classes.footer}>
<Typography variant="body1">Footer</Typography>
<Copyright />
</Container>
)
}
export default Footer

View File

@@ -0,0 +1,103 @@
import React, { useCallback } from 'react'
import clsx from 'clsx'
import AppBar from '@material-ui/core/AppBar'
import Toolbar from '@material-ui/core/Toolbar'
import IconButton from '@material-ui/core/IconButton'
import MenuIcon from '@material-ui/icons/Menu'
import Typography from '@material-ui/core/Typography'
import { Theme, makeStyles, createStyles } from '@material-ui/core/styles'
import { DEFAULT_APP_NAME, DRAWER_WIDTH } from '@constants'
import Profile from './Profile'
import useUser from '@hooks/useUser'
import { useRecoilValue } from 'recoil'
import { currentMenuStateAtom } from '@stores'
import { useTranslation } from 'react-i18next'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
},
menuButton: {
marginRight: theme.spacing(2),
},
title: {
flexGrow: 1,
},
appBar: {
zIndex: theme.zIndex.drawer + 1,
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
},
appBarShift: {
marginLeft: DRAWER_WIDTH,
width: `calc(100% - ${DRAWER_WIDTH}px)`,
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
},
hide: {
display: 'none',
},
toolbar: {
paddingRight: 24, // keep right padding when drawer closed
},
}),
)
export interface IHeaderProps {
open: boolean
onClick: () => void
}
const Header: React.FC<IHeaderProps> = ({ open, onClick }) => {
const classes = useStyles()
const { user } = useUser()
const currentMenu = useRecoilValue(currentMenuStateAtom)
const { i18n } = useTranslation()
const getTitle = useCallback(() => {
if (currentMenu) {
return i18n.language === 'ko'
? currentMenu?.korName
: currentMenu?.engName
}
return DEFAULT_APP_NAME
}, [i18n, currentMenu])
return (
<AppBar
position="fixed"
className={clsx(classes.appBar, {
[classes.appBarShift]: open,
})}
>
<Toolbar className={classes.toolbar}>
<IconButton
color="inherit"
aria-label="open drawer"
onClick={onClick}
edge="start"
className={clsx(classes.menuButton, {
[classes.hide]: open,
})}
>
<MenuIcon />
</IconButton>
<Typography variant="h4" noWrap className={classes.title}>
{getTitle()}
</Typography>
{user && <Profile id={user.userId} email={user.email} />}
</Toolbar>
</AppBar>
)
}
export default Header

View File

@@ -0,0 +1,71 @@
import { Link, Typography } from '@material-ui/core'
import IconButton from '@material-ui/core/IconButton'
import Menu from '@material-ui/core/Menu'
import MenuItem from '@material-ui/core/MenuItem'
import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown'
import { useRouter } from 'next/router'
import React from 'react'
import { useTranslation } from 'react-i18next'
export interface IProfileProps {
id: string
email: string
}
const Profile: React.FC<IProfileProps> = ({ id, email }) => {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null)
const open = Boolean(anchorEl)
const router = useRouter()
const { t } = useTranslation()
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const handleProfileClick = () => {
handleClose()
router.push(`/user/${id}`)
}
return (
<div>
<IconButton
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
>
<Typography variant="subtitle1">{email}</Typography>
<KeyboardArrowDownIcon />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={open}
onClose={handleClose}
>
<MenuItem onClick={handleProfileClick}>Profile</MenuItem>
<MenuItem>
<Link href="/auth/logout">{t('common.logout')}</Link>
</MenuItem>
</Menu>
</div>
)
}
export default Profile

View File

@@ -0,0 +1,144 @@
import React from 'react'
import { useRouter } from 'next/router'
import clsx from 'clsx'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Drawer from '@material-ui/core/Drawer'
import Divider from '@material-ui/core/Divider'
import IconButton from '@material-ui/core/IconButton'
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'
import Link from '@material-ui/core/Link'
import { DRAWER_WIDTH } from '@constants'
import { Menu } from '@components/Menu'
import { Typography } from '@material-ui/core'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
display: 'flex',
},
hide: {
display: 'none',
},
drawer: {
width: DRAWER_WIDTH,
flexShrink: 0,
whiteSpace: 'nowrap',
},
drawerOpen: {
width: DRAWER_WIDTH,
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
},
drawerClose: {
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
overflowX: 'hidden',
width: theme.spacing(7) + 1,
[theme.breakpoints.up('sm')]: {
width: theme.spacing(9) + 1,
},
},
drawerHeader: {
display: 'flex',
alignItems: 'center',
backgroundColor: theme.palette.primary.main,
padding: theme.spacing(0, 1),
// necessary for content to be below app bar
...theme.mixins.toolbar,
justifyContent: 'flex-end',
},
logo: {
// position: 'relative',
padding: theme.spacing(0, 1),
zIndex: 4,
'&:after': {
content: '""',
position: 'absolute',
bottom: '0',
height: '1px',
right: '15px',
width: 'calc(100% - 30px)',
},
},
logoLink: {
marginLeft: '5px',
padding: '5px 0',
display: 'flex',
flexDirection: 'row',
textAlign: 'left',
lineHeight: '30px',
textDecoration: 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
color: theme.palette.common.white,
},
img: {
width: '24px',
height: '24px',
verticalAlign: 'middle',
border: '0',
marginTop: theme.spacing(1),
},
}),
)
interface ISideBarProps {
open: boolean
onClick: () => void
logo?: string
logoText?: string
}
const SideBar = (props: ISideBarProps) => {
const { open, onClick, logo, logoText } = props
const classes = useStyles()
const router = useRouter()
const onLogoClick = (e: React.SyntheticEvent) => {
e.preventDefault()
router.push('/')
}
return (
<Drawer
variant="permanent"
className={clsx(classes.drawer, {
[classes.drawerOpen]: open,
[classes.drawerClose]: !open,
})}
classes={{
paper: clsx({
[classes.drawerOpen]: open,
[classes.drawerClose]: !open,
}),
}}
>
<div className={classes.drawerHeader}>
{logo && (
<div className={classes.logo}>
<Link href="/" onClick={onLogoClick} className={classes.logoLink}>
<img alt="Logo" src={logo} className={classes.img} />
<Typography className={classes.logoLink} variant="h4">
{logoText}
</Typography>
</Link>
</div>
)}
<IconButton onClick={onClick}>
<ChevronLeftIcon />
</IconButton>
</div>
<Divider />
<Menu open={open} />
</Drawer>
)
}
export default SideBar

View File

@@ -0,0 +1,81 @@
import React from 'react'
import { Container, Grid } from '@material-ui/core'
import { makeStyles, createStyles, Theme } from '@material-ui/core/styles'
import SideBar from './SideBar'
import Header from './Header'
import Footer from './Footer'
import { PageProps } from '@pages/_app'
import Bread from './Bread'
import { ADMIN_LOGO_PATH, ADMIN_LOGO_TEXT } from '@constants'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
display: 'flex',
backgroundColor: theme.palette.background.paper,
},
content: {
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
},
container: {
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
marginBottom: theme.spacing(1),
},
toolbar: {
paddingRight: 24, // keep right padding when drawer closed
},
appBarSpacer: theme.mixins.toolbar,
authContent: {
padding: '2.5rem',
},
}),
)
interface ILayoutProps extends PageProps {
children: React.ReactNode
className?: string
}
const Layout: React.FC<ILayoutProps> = props => {
const { children, className } = props
const classes = useStyles()
const [open, setOpen] = React.useState(false)
const handleDrawerOpen = () => {
setOpen(true)
}
const handleDrawerClose = () => {
setOpen(false)
}
return (
<div className={`${classes.root} ${className}`}>
{/* <CssBaseline /> */}
<Header open={open} onClick={handleDrawerOpen} />
<SideBar
open={open}
onClick={handleDrawerClose}
logoText={ADMIN_LOGO_TEXT}
logo={ADMIN_LOGO_PATH}
/>
<main className={classes.content}>
<div className={classes.appBarSpacer} />
<Container maxWidth="lg" className={classes.container}>
<Bread />
{children}
<Grid container spacing={3}></Grid>
</Container>
<Footer />
</main>
</div>
)
}
export { Layout }

View File

@@ -0,0 +1,25 @@
import { CircularProgress, Container } from '@material-ui/core'
import { Theme, makeStyles } from '@material-ui/core/styles'
import React from 'react'
const useStyles = makeStyles((theme: Theme) => ({
container: {
display: 'flex',
height: '100%',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
paddingTop: theme.spacing(10),
},
}))
const Loader: React.FC = () => {
const classes = useStyles()
return (
<Container className={classes.container}>
<CircularProgress size={40} />
</Container>
)
}
export default Loader

View File

@@ -0,0 +1,10 @@
import Box from '@material-ui/core/Box'
import React from 'react'
type Props = {}
const LoginLayout: React.FC<Props> = ({ children }) => {
return <Box>{children}</Box>
}
export default LoginLayout

View File

@@ -0,0 +1,119 @@
import Collapse from '@material-ui/core/Collapse'
import Icon from '@material-ui/core/Icon'
import List from '@material-ui/core/List'
import ListItem from '@material-ui/core/ListItem'
import ListItemIcon from '@material-ui/core/ListItemIcon'
import ListItemText from '@material-ui/core/ListItemText'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import ExpandLess from '@material-ui/icons/ExpandLess'
import ExpandMore from '@material-ui/icons/ExpandMore'
import { currentMenuStateAtom, ISideMenu } from '@stores'
import theme from '@styles/theme'
import { useRouter } from 'next/router'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRecoilValue } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
'& .MuiListItemIcon-root': {
minWidth: '36px',
},
},
menuItem: {
borderBottom: '1px solid #edf1f7',
display: 'flex',
overflow: 'hidden',
width: 'auto',
transition: 'all 300ms linear',
position: 'relative',
backgroundColor: 'transparent',
},
active: {
color: theme.palette.primary.main,
},
}),
)
export interface IMenuItemProps extends ISideMenu {
drawerOpen: boolean
}
/**
* @TODO
* 3단계 이상 그려지는 메뉴 처리
*/
const MenuItem: React.FC<IMenuItemProps> = props => {
const { expanded, drawerOpen } = props
const classes = useStyles()
const router = useRouter()
const { i18n } = useTranslation()
const current = useRecoilValue(currentMenuStateAtom)
const [open, setOpen] = useState<boolean>(expanded || false)
const onClick = (item: ISideMenu) => {
if (item.children.filter(i => i.isShow).length > 0) {
setOpen(!open)
} else {
router.push(item.urlPath)
}
}
const drawItem = useCallback(
(item: ISideMenu) => {
const active =
current?.id === item.id
? true
: item.children?.findIndex(ele => ele.id === current?.id) > -1
? true
: false
return (
<div key={`list-item-div-${item.id}`} className={classes.root}>
<ListItem
button
key={`list-item-${item.id}`}
onClick={() => onClick(item)}
className={`${classes.menuItem} ${active ? classes.active : null}`}
style={{
paddingLeft: theme.spacing(
item.level * (item.level === 1 ? 3 : 2),
),
}}
>
<ListItemIcon className={active ? classes.active : null}>
<Icon> {item.icon || 'folder'}</Icon>
</ListItemIcon>
{drawerOpen && (
<ListItemText
key={`item-text-${item.id}`}
primary={i18n.language === 'ko' ? item.korName : item.engName}
/>
)}
{drawerOpen &&
item.children.filter(i => i.isShow).length > 0 &&
(open ? <ExpandLess /> : <ExpandMore />)}
</ListItem>
{item.children.filter(i => i.isShow).length > 0 ? (
<Collapse in={open && drawerOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{item.children.filter(i => i.isShow).map(i => drawItem(i))}
</List>
</Collapse>
) : null}
</div>
)
},
[props, open],
)
return <>{drawItem(props)}</>
}
export default MenuItem

View File

@@ -0,0 +1,36 @@
import React from 'react'
import { List } from '@material-ui/core'
import { makeStyles, createStyles, Theme } from '@material-ui/core/styles'
import MenuItem from './MenuItem'
import { useRecoilValue } from 'recoil'
import { menuStateAtom } from '@stores'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
listStyle: 'none',
position: 'unset',
},
}),
)
export interface IMenuProps {
open: boolean
}
const Menu = ({ open }: IMenuProps) => {
const classes = useStyles()
const menus = useRecoilValue(menuStateAtom)
return (
<List component="nav" className={classes.root}>
{menus
.filter(item => item.isShow)
.map(item => (
<MenuItem key={`menu-item-${item.id}`} {...item} drawerOpen={open} />
))}
</List>
)
}
export { Menu }

View File

@@ -0,0 +1,65 @@
import React from 'react'
import FormControl from '@material-ui/core/FormControl'
import FormControlLabel, {
FormControlLabelProps,
} from '@material-ui/core/FormControlLabel'
import FormLabel from '@material-ui/core/FormLabel'
import Radio from '@material-ui/core/Radio'
import RadioGroup from '@material-ui/core/RadioGroup'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
border: '1px solid rgba(0, 0, 0, 0.23)',
borderRadius: '4px',
padding: theme.spacing(0, 1),
marginBottom: theme.spacing(1),
},
label: {
fontSize: '0.75rem',
},
}),
)
export interface IRadioData extends Omit<FormControlLabelProps, 'control'> {}
export interface RadioGroupFieldProps {
data: IRadioData[]
label: string
required?: boolean
error?: boolean
}
const RadioGroupField = ({
data,
label,
required = false,
error = false,
}: RadioGroupFieldProps) => {
const classes = useStyles()
return (
<FormControl error={error} component="fieldset" className={classes.root}>
<FormLabel
className={classes.label}
error={error}
required={required}
component="legend"
>
{label}
</FormLabel>
<RadioGroup row aria-label="position" name="position" defaultValue="top">
{data &&
data.map((item, index) => (
<FormControlLabel
key={`radio-group-${label}-${index}`}
control={<Radio color="primary" />}
{...item}
/>
))}
</RadioGroup>
</FormControl>
)
}
export default RadioGroupField

View File

@@ -0,0 +1,197 @@
import { ControlledTextField } from '@components/ControlledField'
import DialogPopup from '@components/DialogPopup'
import DisableTextField from '@components/DisableTextField'
import Button from '@material-ui/core/Button'
import Card from '@material-ui/core/Card'
import CardActions from '@material-ui/core/CardActions'
import CardContent from '@material-ui/core/CardContent'
import CardHeader from '@material-ui/core/CardHeader'
import Collapse from '@material-ui/core/Collapse'
import Divider from '@material-ui/core/Divider'
import IconButton from '@material-ui/core/IconButton'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Typography from '@material-ui/core/Typography'
import ExpandLessIcon from '@material-ui/icons/ExpandLess'
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
import User from '@pages/user'
import { IReserve, IUser, ReserveFormProps } from '@service'
import { useTranslation } from 'next-i18next'
import React, { useEffect, useState } from 'react'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginBottom: theme.spacing(2),
'& .MuiInputLabel-outlined': {
zIndex: 0,
},
},
header: {
justifyContent: 'space-between',
},
container: {
display: 'flex',
flexDirection: 'column',
},
button: {
marginLeft: theme.spacing(4),
padding: theme.spacing(0, 1),
},
content: {
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBottom: '2rem',
},
pos: {
marginTop: theme.spacing(1),
marginBottom: '3rem',
},
}),
)
interface ReserveClientInfoProps extends ReserveFormProps {
data: IReserve
}
export interface IUserInfo {
email: string
userId: string
userName: string
}
const ReserveClientInfo = (props: ReserveClientInfoProps) => {
const { control, formState, data, setValue } = props
const classes = useStyles()
const { t } = useTranslation()
const [expanded, setExpanded] = useState<boolean>(true)
const [dialogOpen, setDialogOpen] = useState<boolean>(false)
const [user, setUser] = useState<IUserInfo | null>(null)
useEffect(() => {
if (data) {
setUser({
email: data.userEmail,
userId: data.userId,
userName: data.userName,
})
}
}, [data])
const handleExpandClick = () => {
setExpanded(!expanded)
}
const handlePopup = (userData: IUser) => {
if (userData) {
setUser(userData)
setValue('userEmail', userData.email, {
shouldValidate: true,
})
setValue('userId', userData.userId)
}
handleDialogClose()
}
const handleDialogOpen = () => {
setDialogOpen(true)
}
const handleDialogClose = () => {
setDialogOpen(false)
}
return (
<Card className={classes.root}>
<CardActions className={classes.header}>
<CardHeader title={`${t('reserve.user')} ${t('common.information')}`} />
<IconButton onClick={handleExpandClick}>
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</CardActions>
<Divider />
<Collapse in={expanded} timeout="auto" unmountOnExit>
{user ? (
<CardContent className={classes.container}>
<DisableTextField
label={t('label.title.name')}
value={
<>
{user.userName}
<Button
className={classes.button}
size="small"
variant="contained"
color="primary"
onClick={handleDialogOpen}
>
{`${t('reserve.user')} ${t('common.search')}`}
</Button>
</>
}
labelProps={{
xs: 4,
sm: 2,
}}
valueProps={{
xs: 8,
sm: 10,
}}
/>
<ControlledTextField
control={control}
formState={formState}
name="userContactNo"
label={t('reserve.phone')}
defaultValue={''}
textFieldProps={{
required: true,
}}
/>
<ControlledTextField
control={control}
formState={formState}
name="userEmail"
label={t('user.email')}
defaultValue={user?.email || ''}
textFieldProps={{
required: true,
}}
/>
</CardContent>
) : (
<CardContent className={classes.content}>
<Typography
className={classes.pos}
variant="h5"
color="textSecondary"
>
{t('reserve.msg.find_user')}
</Typography>
<Button
variant="contained"
color="primary"
onClick={handleDialogOpen}
>
{`${t('reserve.user')} ${t('common.search')}`}
</Button>
</CardContent>
)}
<DialogPopup
id="find-dialog"
handleClose={handleDialogClose}
open={dialogOpen}
title={`${t('common.user')} ${t('label.button.find')}`}
>
<User handlePopup={handlePopup} />
</DialogPopup>
</Collapse>
</Card>
)
}
export { ReserveClientInfo }

View File

@@ -0,0 +1,60 @@
import { ControlledTextField } from '@components/ControlledField'
import { IReserve, ReserveFormProps } from '@service'
import React, { useEffect } from 'react'
import { UseFormSetError, useWatch } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
interface ReserveEduInfoProps extends ReserveFormProps {
totalQty: number
setError: UseFormSetError<IReserve>
}
const ReserveEduInfo = (props: ReserveEduInfoProps) => {
const { control, formState, totalQty, setError } = props
const { t } = useTranslation()
const watchReserveQty = useWatch({
control,
name: 'reserveQty',
})
useEffect(() => {
if (watchReserveQty) {
if (watchReserveQty > totalQty) {
setError(
'reserveQty',
{ message: t('valid.reserve.number_of_people') },
{ shouldFocus: true },
)
}
}
}, [watchReserveQty])
return (
<>
<ControlledTextField
control={control}
formState={formState}
name="reserveQty"
label={`${t('reserve.request')} ${t('reserve.number_of_people')}`}
defaultValue={''}
textFieldProps={{
required: true,
type: 'number',
}}
/>
<ControlledTextField
control={control}
formState={formState}
name="reservePurposeContent"
label={`${t('reserve')} ${t('reserve.purpose')}`}
defaultValue={''}
textFieldProps={{
required: true,
}}
/>
</>
)
}
export { ReserveEduInfo }

View File

@@ -0,0 +1,231 @@
import {
ControlledDateRangePicker,
ControlledTextField,
} from '@components/ControlledField'
import { convertStringToDate, convertStringToDateFormat } from '@libs/date'
import FormHelperText from '@material-ui/core/FormHelperText'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import {
IReserve,
IReserveItemRelation,
ReserveFormProps,
reserveService,
} from '@service'
import { errorStateSelector } from '@stores'
import { format } from '@utils'
import isAfter from 'date-fns/isAfter'
import isBefore from 'date-fns/isBefore'
import React, { useEffect, useState } from 'react'
import { UseFormClearErrors, UseFormSetError, useWatch } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { useSetRecoilState } from 'recoil'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
help: {
marginLeft: theme.spacing(2),
width: '20em',
},
}),
)
interface ReserveEquipInfoProps extends ReserveFormProps {
item: IReserveItemRelation
setError: UseFormSetError<IReserve>
clearErrors: UseFormClearErrors<IReserve>
}
const ReserveEquipInfo = (props: ReserveEquipInfoProps) => {
const { control, formState, getValues, item, setError, clearErrors } = props
const classes = useStyles()
const { t } = useTranslation()
const setErrorState = useSetRecoilState(errorStateSelector)
const [inventory, setInventory] = useState<number | null>(0)
const [compareDate, setCompareDate] = useState<{
startDate: Date
endDate: Date
} | null>(null)
const watchStartDate = useWatch({
control,
name: 'reserveStartDate',
})
const watchEndDate = useWatch({
control,
name: 'reserveEndDate',
})
useEffect(() => {
if (item) {
let startDate = item.operationStartDate
let endDate = item.operationEndDate
if (item.reserveMeansId === 'realtime') {
startDate = item.requestStartDate
endDate = item.requestEndDate
}
setCompareDate({
startDate: convertStringToDate(startDate),
endDate: convertStringToDate(endDate),
})
}
}, [item])
useEffect(() => {
if (watchStartDate && compareDate) {
if (isBefore(watchStartDate, compareDate.startDate)) {
setError(
'reserveStartDate',
{
message: format(t('valid.to_be_fast.format'), [
`${t('reserve.request')} ${t('common.start_date')}`,
`${t('reserve_item.operation')}/${t('reserve_item.request')} ${t(
'reserve.period',
)}`,
]),
},
{ shouldFocus: true },
)
} else if (isAfter(watchStartDate, compareDate.endDate)) {
setError(
'reserveStartDate',
{
message: format(t('valid.to_be_slow.format'), [
`${t('reserve.request')} ${t('common.start_date')}`,
`${t('reserve_item.operation')}/${t('reserve_item.request')} ${t(
'reserve.period',
)}`,
]),
},
{ shouldFocus: true },
)
} else {
clearErrors('reserveStartDate')
}
}
}, [watchStartDate])
useEffect(() => {
if (watchEndDate && compareDate) {
if (isBefore(watchEndDate, compareDate.startDate)) {
setError(
'reserveEndDate',
{
message: format(t('valid.to_be_fast.format'), [
`${t('reserve.request')} ${t('common.end_date')}`,
`${t('reserve_item.operation')}/${t('reserve_item.request')} ${t(
'reserve.period',
)}`,
]),
},
{ shouldFocus: true },
)
} else if (isAfter(watchEndDate, compareDate.endDate)) {
setError(
'reserveEndDate',
{
message: format(t('valid.to_be_slow.format'), [
`${t('reserve.request')} ${t('common.end_date')}`,
`${t('reserve_item.operation')}/${t('reserve_item.request')} ${t(
'reserve.period',
)}`,
]),
},
{ shouldFocus: true },
)
} else {
clearErrors('reserveEndDate')
}
}
}, [watchEndDate])
useEffect(() => {
if (watchStartDate && watchEndDate) {
if (
!formState.errors.reserveStartDate &&
!formState.errors.reserveEndDate
) {
reserveService
.getInventories(
item.reserveItemId,
convertStringToDateFormat(watchStartDate),
convertStringToDateFormat(watchEndDate),
)
.then(result => {
setInventory(result.data)
})
.catch(error => {
setErrorState({ error })
})
}
}
}, [watchStartDate, watchEndDate])
return (
<>
<ControlledDateRangePicker
control={control}
formState={formState}
getValues={getValues}
required={true}
startProps={{
label: `${t('reserve.request')} ${t('common.start_date')}`,
name: 'reserveStartDate',
contollerProps: {
rules: {
required: true,
},
},
}}
endProps={{
label: `${t('reserve.request')} ${t('common.end_date')}`,
name: 'reserveEndDate',
contollerProps: {
rules: {
required: true,
},
},
}}
/>
<ControlledTextField
control={control}
formState={formState}
name="reserveQty"
label={`${t('reserve.request')} ${t('reserve.count')}`}
defaultValue={0}
textFieldProps={{
required: true,
type: 'number',
}}
contollerProps={{
rules: {
required: true,
pattern: {
value: /^[0-9]*$/,
message: t('valid.valueAsNumber'),
},
},
}}
help={
<FormHelperText error className={classes.help}>
({t('신청기간내 예약가능 수량')}: {inventory} )
</FormHelperText>
}
/>
<ControlledTextField
control={control}
formState={formState}
name="reservePurposeContent"
label={`${t('reserve.request')} ${t('reserve.purpose')}`}
defaultValue={''}
textFieldProps={{
required: true,
}}
/>
</>
)
}
export { ReserveEquipInfo }

View File

@@ -0,0 +1,176 @@
import AttachList from '@components/AttachList'
import { Upload, UploadType } from '@components/Upload'
import Box from '@material-ui/core/Box'
import Card from '@material-ui/core/Card'
import CardActions from '@material-ui/core/CardActions'
import CardContent from '@material-ui/core/CardContent'
import CardHeader from '@material-ui/core/CardHeader'
import Collapse from '@material-ui/core/Collapse'
import Divider from '@material-ui/core/Divider'
import IconButton from '@material-ui/core/IconButton'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
import {
IAttachmentResponse,
IReserve,
IReserveItemRelation,
ReserveFormProps,
} from '@service'
import { errorStateSelector } from '@stores'
import React, { useEffect, useState } from 'react'
import { UseFormClearErrors, UseFormSetError } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { useSetRecoilState } from 'recoil'
import { ReserveEduInfo } from './ReserveEduInfo'
import { ReserveEquipInfo } from './ReserveEquipInfo'
import { ReserveSpaceInfo } from './ReserveSpaceInfo'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginBottom: theme.spacing(2),
'& .MuiInputLabel-outlined': {
zIndex: 0,
},
},
header: {
justifyContent: 'space-between',
},
container: {
display: 'flex',
flexDirection: 'column',
},
attach: {
borderRadius: theme.spacing(0.5),
marginTop: theme.spacing(1),
},
}),
)
interface ReserveInfoProps extends ReserveFormProps {
data?: IReserve
item: IReserveItemRelation
setError: UseFormSetError<IReserve>
clearErrors: UseFormClearErrors<IReserve>
fileProps: {
uploadRef: React.MutableRefObject<UploadType>
attachData: IAttachmentResponse[] | undefined
setAttachData: React.Dispatch<React.SetStateAction<IAttachmentResponse[]>>
}
}
const containKeys: string[] = [
'reserveItemId',
'reserveQty',
'reservePurposeContent',
'attachmentCode',
'reserveStartDate',
'reserveEndDate',
]
const ReserveInfo = (props: ReserveInfoProps) => {
const {
control,
formState,
register,
getValues,
data,
item,
setError,
clearErrors,
fileProps,
} = props
const classes = useStyles()
const { t } = useTranslation()
const [expanded, setExpanded] = useState<boolean>(true)
const [errorText, setErrorText] = useState<string | undefined>(undefined)
// 상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
useEffect(() => {
if (formState.errors) {
const keys = Object.keys(formState.errors)
const found = keys.some(r => containKeys.includes(r))
if (keys.length > 0 && found) {
setErrorText('입력값이 잘못 되었습니다.')
} else {
setErrorText(undefined)
}
}
}, [formState.errors])
const handleExpandClick = () => {
setExpanded(!expanded)
}
return (
<>
<Card className={classes.root}>
<CardActions className={classes.header}>
<CardHeader
title={`${t('reserve')} ${t('common.information')}`}
subheader={errorText && errorText}
subheaderTypographyProps={{
color: 'error',
}}
/>
<IconButton onClick={handleExpandClick}>
<ExpandMoreIcon />
</IconButton>
</CardActions>
<Divider />
<Collapse in={expanded} timeout="auto" unmountOnExit>
<CardContent className={classes.container}>
{item?.categoryId === 'education' ? (
<ReserveEduInfo
control={control}
formState={formState}
totalQty={item?.totalQty}
setError={setError}
/>
) : item?.categoryId === 'space' ? (
<ReserveSpaceInfo
control={control}
formState={formState}
register={register}
getValues={getValues}
item={item}
setError={setError}
clearErrors={clearErrors}
/>
) : item?.categoryId === 'equipment' ? (
<ReserveEquipInfo
control={control}
formState={formState}
register={register}
getValues={getValues}
item={item}
setError={setError}
clearErrors={clearErrors}
/>
) : null}
<Box boxShadow={1} className={classes.attach}>
<Upload
ref={fileProps?.uploadRef}
multi
attachmentCode={data?.attachmentCode}
attachData={fileProps?.attachData}
/>
{fileProps?.attachData && (
<AttachList
data={fileProps.attachData}
setData={fileProps.setAttachData}
/>
)}
</Box>
</CardContent>
</Collapse>
</Card>
</>
)
}
export { ReserveInfo }

View File

@@ -0,0 +1,251 @@
import AttachList from '@components/AttachList'
import { CustomButtons, IButtonProps } from '@components/Buttons'
import DialogPopup from '@components/DialogPopup'
import DisableTextField from '@components/DisableTextField'
import ValidationAlert from '@components/EditForm/ValidationAlert'
import { convertStringToDateFormat } from '@libs/date'
import Button from '@material-ui/core/Button'
import Card from '@material-ui/core/Card'
import CardActions from '@material-ui/core/CardActions'
import CardContent from '@material-ui/core/CardContent'
import CardHeader from '@material-ui/core/CardHeader'
import Collapse from '@material-ui/core/Collapse'
import Divider from '@material-ui/core/Divider'
import Grid from '@material-ui/core/Grid'
import IconButton from '@material-ui/core/IconButton'
import Paper from '@material-ui/core/Paper'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import TextField from '@material-ui/core/TextField'
import Typography from '@material-ui/core/Typography'
import ExpandLessIcon from '@material-ui/icons/ExpandLess'
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
import { IAttachmentResponse, IReserve } from '@service'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginBottom: theme.spacing(2),
'& .MuiInputLabel-outlined': {
zIndex: 0,
},
},
header: {
justifyContent: 'space-between',
},
container: {
display: 'flex',
flexDirection: 'column',
},
attach: {
borderRadius: theme.spacing(0.5),
marginTop: theme.spacing(1),
},
label: {
padding: theme.spacing(1),
textAlign: 'center',
backgroundColor: theme.palette.background.default,
},
}),
)
interface ReserveInfoViewProps {
data: IReserve
attachData?: IAttachmentResponse[]
handleList: () => void
handleButtons: (status: string, reason?: string) => void
}
const ReserveInfoView = ({
data,
attachData,
handleList,
handleButtons,
}: ReserveInfoViewProps) => {
const classes = useStyles()
const { t } = useTranslation()
const [reason, setReason] = useState<string>('')
const [reasonError, setReasonError] = useState<boolean>(false)
const [expanded, setExpanded] = useState<boolean>(true)
const [dialogOpen, setDialogOpen] = useState<boolean>(false)
useEffect(() => {
if (reason.length > 0) {
setReasonError(false)
}
}, [reason])
const handleExpandClick = () => {
setExpanded(!expanded)
}
const handleDialogOpen = () => {
setDialogOpen(true)
}
const handleDialogClose = () => {
setDialogOpen(false)
}
const handleCancel = () => {
if (reason.length <= 0) {
setReasonError(true)
return
}
handleButtons('cancel', reason)
}
const buttons = useCallback(() => {
let bs: IButtonProps[] = []
if (
data?.reserveStatusId === 'request' ||
data?.reserveStatusId === 'cancel'
) {
bs.push({
label: `${t('reserve')} ${t('common.approve')}`,
confirmMessage: `${t('reserve')} ${t('common.approve')}${t(
'common.msg.would.format',
)}`,
handleButton: () => {
handleButtons('approve')
},
completeMessage: `${t('reserve')} ${t('common.approve')}${t(
'common.msg.done.format',
)}`,
variant: 'contained',
color: 'primary',
})
}
if (
data?.reserveStatusId === 'request' ||
data?.reserveStatusId === 'approve'
) {
bs.push({
label: `${t('reserve')} ${t('common.cancel')}`,
confirmMessage: `${t('reserve')} ${t('common.cancel')}${t(
'common.msg.would.format',
)}`,
handleButton: handleDialogOpen,
completeMessage: `${t('reserve')} ${t('common.cancel')}${t(
'common.msg.done.format',
)}`,
variant: 'contained',
color: 'secondary',
})
}
bs.push({
label: t('label.button.list'),
handleButton: handleList,
variant: 'contained',
})
return bs
}, [data])
return (
<>
<Card className={classes.root}>
<CardActions className={classes.header}>
<CardHeader title={`${t('reserve')} ${t('common.information')}`} />
<IconButton onClick={handleExpandClick}>
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</CardActions>
<Divider />
<Collapse in={expanded} timeout="auto" unmountOnExit>
<CardContent className={classes.container}>
<Grid container spacing={1}>
{data.reserveItem.categoryId === 'education' ? null : (
<Grid item xs={12}>
<DisableTextField
label={`${t('reserve.request')} ${t('common.period')}`}
value={`${convertStringToDateFormat(
data.reserveStartDate,
)}~${convertStringToDateFormat(data.reserveEndDate)}`}
/>
</Grid>
)}
{data.reserveItem.categoryId === 'space' ? null : (
<Grid item xs={12}>
<DisableTextField
label={
data.reserveItem.categoryId === 'education'
? `${t('reserve.request')} ${t(
'reserve.number_of_people',
)}`
: `${t('reserve.request')} ${t('reserve.count')}`
}
value={data.reserveQty}
/>
</Grid>
)}
<Grid item xs={12}>
<DisableTextField
label={`${t('reserve.request')} ${t('reserve.purpose')}`}
value={data.reservePurposeContent}
/>
</Grid>
<Grid item xs={12}>
<Paper className={classes.label}>
<Typography variant="body1">
{t('common.attachment')}
</Typography>
</Paper>
<AttachList data={attachData} readonly={true} />
</Grid>
</Grid>
</CardContent>
</Collapse>
</Card>
<CustomButtons buttons={buttons()} />
<DialogPopup
id="find-dialog"
handleClose={handleDialogClose}
open={dialogOpen}
title={`${t('reserve')} ${t('common.cancel')}`}
action={{
props: {},
children: (
<>
<Button
onClick={handleCancel}
variant="contained"
color="secondary"
>
{`${t('reserve')} ${t('common.cancel')}`}
</Button>
<Button onClick={handleDialogClose} variant="contained">
{t('label.button.close')}
</Button>
</>
),
}}
>
<TextField
autoFocus
margin="dense"
id="reason"
label={t('reserve.cancel_reason')}
type="text"
fullWidth
error={reasonError}
value={reason}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setReason(e.target.value)
}}
/>
{reasonError && (
<ValidationAlert message={t('reserve.msg.calcel_reason')} />
)}
</DialogPopup>
</>
)
}
export { ReserveInfoView }

View File

@@ -0,0 +1,200 @@
import DisableTextField from '@components/DisableTextField'
import { convertStringToDateFormat } from '@libs/date'
import Button from '@material-ui/core/Button'
import Card from '@material-ui/core/Card'
import CardActions from '@material-ui/core/CardActions'
import CardContent from '@material-ui/core/CardContent'
import CardHeader from '@material-ui/core/CardHeader'
import Collapse from '@material-ui/core/Collapse'
import Divider from '@material-ui/core/Divider'
import Grid from '@material-ui/core/Grid'
import IconButton from '@material-ui/core/IconButton'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Typography from '@material-ui/core/Typography'
import ExpandLessIcon from '@material-ui/icons/ExpandLess'
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
import { ICode, IReserveItemRelation } from '@service'
import { useTranslation } from 'next-i18next'
import React, { useState } from 'react'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginBottom: theme.spacing(2),
'& .MuiInputLabel-outlined': {
zIndex: 0,
},
},
header: {
justifyContent: 'space-between',
},
container: {
display: 'flex',
flexDirection: 'column',
},
button: {
marginLeft: theme.spacing(4),
padding: theme.spacing(0, 1),
},
}),
)
interface ReserveItemInfoProps {
data: IReserveItemRelation
handleSearchItem: () => void
reserveStatus?: ICode
}
const ReserveItemInfo = (props: ReserveItemInfoProps) => {
const { data, handleSearchItem, reserveStatus } = props
const classes = useStyles()
const { t } = useTranslation()
const [expanded, setExpanded] = useState<boolean>(true)
const handleExpandClick = () => {
setExpanded(!expanded)
}
return (
<Card className={classes.root}>
<CardActions className={classes.header}>
<CardHeader title={`${t('reserve_item')} ${t('common.information')}`} />
<IconButton onClick={handleExpandClick}>
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</CardActions>
<Divider />
<Collapse in={expanded} timeout="auto" unmountOnExit>
<CardContent className={classes.container}>
<Grid container spacing={1}>
<Grid item xs={12} sm={6}>
<DisableTextField
label={t('location')}
value={data.location.locationName}
/>
</Grid>
<Grid item xs={12} sm={6}>
<DisableTextField
label={t('reserve_item.type')}
value={data.categoryName}
/>
</Grid>
<Grid item xs={12} sm={12}>
<DisableTextField
label={t('reserve_item.name')}
value={
<>
{data.reserveItemName}
{reserveStatus ? null : (
<Button
className={classes.button}
size="small"
variant="contained"
color="primary"
onClick={handleSearchItem}
>
{`${t('reserve_item')} ${t('label.button.change')}`}
</Button>
)}
</>
}
labelProps={{
xs: 4,
sm: 2,
}}
valueProps={{
xs: 8,
sm: 10,
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<DisableTextField
label={
data.categoryId === 'education'
? t('reserve.number_of_people')
: t('reserve.count')
}
value={data.totalQty}
/>
</Grid>
<Grid item xs={12} sm={6}>
<DisableTextField
label={t('reserve_item.selection_means')}
value={data.selectionMeansName}
/>
</Grid>
<Grid item xs={12} sm={6}>
<DisableTextField
label={`${t('reserve_item.operation')} ${t('reserve.period')}`}
value={`${convertStringToDateFormat(
data.operationStartDate,
)} ~ ${convertStringToDateFormat(data.operationEndDate)}`}
/>
</Grid>
<Grid item xs={12} sm={6}>
<DisableTextField
label={`${t('reserve_item.request')} ${t('reserve.period')}`}
value={
<Typography variant="body1" noWrap>
{`${convertStringToDateFormat(
data.requestStartDate,
'yyyy-MM-dd HH:mm',
)}
~ ${convertStringToDateFormat(
data.requestEndDate,
'yyyy-MM-dd HH:mm',
)}
`}
</Typography>
}
/>
</Grid>
<Grid item xs={12} sm={12}>
<DisableTextField
label={`${t('common.free')} ${t('common.paid')}`}
value={data.isPaid ? t('common.paid') : t('common.free')}
labelProps={{
xs: 4,
sm: 2,
}}
valueProps={{
xs: 8,
sm: 10,
}}
/>
</Grid>
{reserveStatus && (
<Grid item xs={12}>
<DisableTextField
label={t('reserve.status')}
value={
reserveStatus.codeId === 'request' ? (
<Typography variant="body1" color="error">
{reserveStatus.codeName}
</Typography>
) : (
reserveStatus.codeName
)
}
labelProps={{
xs: 4,
sm: 2,
}}
valueProps={{
xs: 8,
sm: 10,
}}
/>
</Grid>
)}
</Grid>
</CardContent>
</Collapse>
</Card>
)
}
export { ReserveItemInfo }

View File

@@ -0,0 +1,163 @@
import {
ControlledDateRangePicker,
ControlledTextField,
} from '@components/ControlledField'
import { convertStringToDate } from '@libs/date'
import { IReserve, IReserveItemRelation, ReserveFormProps } from '@service'
import { format } from '@utils'
import isAfter from 'date-fns/isAfter'
import isBefore from 'date-fns/isBefore'
import React, { useEffect, useState } from 'react'
import { UseFormClearErrors, UseFormSetError, useWatch } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
interface ReserveSpaceInfoProps extends ReserveFormProps {
item: IReserveItemRelation
setError: UseFormSetError<IReserve>
clearErrors: UseFormClearErrors<IReserve>
}
const ReserveSpaceInfo = (props: ReserveSpaceInfoProps) => {
const { control, formState, getValues, item, setError, clearErrors } = props
const { t } = useTranslation()
const [compareDate, setCompareDate] = useState<{
startDate: Date
endDate: Date
} | null>(null)
const watchStartDate = useWatch({
control,
name: 'reserveStartDate',
})
const watchEndDate = useWatch({
control,
name: 'reserveEndDate',
})
useEffect(() => {
if (item) {
let startDate = item.operationStartDate
let endDate = item.operationEndDate
if (item.reserveMeansId === 'realtime') {
startDate = item.requestStartDate
endDate = item.requestEndDate
}
setCompareDate({
startDate: convertStringToDate(startDate),
endDate: convertStringToDate(endDate),
})
}
}, [item])
useEffect(() => {
if (watchStartDate && compareDate) {
if (isBefore(watchStartDate, compareDate.startDate)) {
setError(
'reserveStartDate',
{
message: format(t('valid.to_be_fast.format'), [
`${t('reserve.request')} ${t('common.start_date')}`,
`${t('reserve_item.operation')}/${t('reserve_item.request')} ${t(
'reserve.period',
)}`,
]),
},
{ shouldFocus: true },
)
} else if (isAfter(watchStartDate, compareDate.endDate)) {
setError(
'reserveStartDate',
{
message: format(t('valid.to_be_slow.format'), [
`${t('reserve.request')} ${t('common.start_date')}`,
`${t('reserve_item.operation')}/${t('reserve_item.request')} ${t(
'reserve.period',
)}`,
]),
},
{ shouldFocus: true },
)
} else {
clearErrors('reserveStartDate')
}
}
}, [watchStartDate])
useEffect(() => {
if (watchEndDate && compareDate) {
if (isBefore(watchEndDate, compareDate.startDate)) {
setError(
'reserveEndDate',
{
message: format(t('valid.to_be_fast.format'), [
`${t('reserve.request')} ${t('common.end_date')}`,
`${t('reserve_item.operation')}/${t('reserve_item.request')} ${t(
'reserve.period',
)}`,
]),
},
{ shouldFocus: true },
)
} else if (isAfter(watchEndDate, compareDate.endDate)) {
setError(
'reserveEndDate',
{
message: format(t('valid.to_be_slow.format'), [
`${t('reserve.request')} ${t('common.end_date')}`,
`${t('reserve_item.operation')}/${t('reserve_item.request')} ${t(
'reserve.period',
)}`,
]),
},
{ shouldFocus: true },
)
} else {
clearErrors('reserveEndDate')
}
}
}, [watchEndDate])
return (
<>
<ControlledDateRangePicker
control={control}
formState={formState}
getValues={getValues}
required={true}
startProps={{
label: `${t('reserve.request')} ${t('common.start_date')}`,
name: 'reserveStartDate',
minDate: new Date(),
contollerProps: {
rules: {
required: true,
},
},
}}
endProps={{
label: `${t('reserve.request')} ${t('common.end_date')}`,
name: 'reserveEndDate',
contollerProps: {
rules: {
required: true,
},
},
}}
/>
<ControlledTextField
control={control}
formState={formState}
name="reservePurposeContent"
label={`${t('reserve.request')} ${t('reserve.purpose')}`}
defaultValue={''}
textFieldProps={{
required: true,
}}
/>
</>
)
}
export { ReserveSpaceInfo }

View File

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

View File

@@ -0,0 +1,185 @@
import {
ControlledRadioField,
ControlledTextField,
} from '@components/ControlledField'
import Card from '@material-ui/core/Card'
import CardActions from '@material-ui/core/CardActions'
import CardContent from '@material-ui/core/CardContent'
import CardHeader from '@material-ui/core/CardHeader'
import Collapse from '@material-ui/core/Collapse'
import Divider from '@material-ui/core/Divider'
import IconButton from '@material-ui/core/IconButton'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
import { ICode, ReserveItemFormProps } from '@service'
import { useTranslation } from 'next-i18next'
import React, { useEffect, useState } from 'react'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginBottom: theme.spacing(2),
'& .MuiInputLabel-outlined': {
zIndex: 0,
},
},
header: {
justifyContent: 'space-between',
},
container: {
display: 'flex',
flexDirection: 'column',
},
}),
)
interface ReserveItemAdditionalProps extends ReserveItemFormProps {
targets: ICode[]
}
const containKeys: string[] = [
'purpose',
'address',
'targetId',
'excluded',
'homepage',
'contact',
]
const ReserveItemAdditional = (props: ReserveItemAdditionalProps) => {
const { control, formState, targets } = props
const classes = useStyles()
const { t, i18n } = useTranslation()
const [expanded, setExpanded] = useState<boolean>(true)
const [errorText, setErrorText] = useState<string | undefined>(undefined)
useEffect(() => {
if (formState.errors) {
const keys = Object.keys(formState.errors)
const found = keys.some(r => containKeys.includes(r))
if (keys.length > 0 && found) {
setErrorText('입력값이 잘못 되었습니다.')
} else {
setErrorText(undefined)
}
}
}, [formState.errors])
const handleExpandClick = () => {
setExpanded(!expanded)
}
return (
<>
<Card className={classes.root}>
<CardActions className={classes.header}>
<CardHeader
title={t('reserve_item.add_information')}
subheader={errorText && errorText}
subheaderTypographyProps={{
color: 'error',
}}
/>
<IconButton onClick={handleExpandClick}>
<ExpandMoreIcon />
</IconButton>
</CardActions>
<Divider />
<Collapse in={expanded} timeout="auto" unmountOnExit>
<CardContent className={classes.container}>
<ControlledTextField
control={control}
formState={formState}
name="purpose"
label={t('reserve_item.purpose')}
defaultValue={''}
contollerProps={{
rules: {
maxLength: 4000,
},
}}
textFieldProps={{
required: false,
}}
/>
<ControlledTextField
control={control}
formState={formState}
name="address"
label={t('common.address')}
defaultValue={''}
contollerProps={{
rules: {
maxLength: 500,
},
}}
textFieldProps={{
required: false,
}}
/>
<ControlledRadioField
control={control}
formState={formState}
name="targetId"
label={t('reserve_item.target')}
defaultValue={'no-limit'}
requried={true}
data={{
idkey: 'codeId',
namekey: 'codeName',
data: targets,
}}
/>
<ControlledTextField
control={control}
formState={formState}
name="excluded"
label={t('reserve_item.excluded')}
defaultValue={''}
contollerProps={{
rules: {
maxLength: 2000,
},
}}
textFieldProps={{
required: false,
}}
/>
<ControlledTextField
control={control}
formState={formState}
name="homepage"
label={t('common.home_page_address')}
defaultValue={''}
contollerProps={{
rules: {
maxLength: 500,
},
}}
textFieldProps={{
required: false,
}}
/>
<ControlledTextField
control={control}
formState={formState}
name="contact"
label={t('reserve_item.contact')}
defaultValue={''}
contollerProps={{
rules: {
maxLength: 50,
},
}}
textFieldProps={{
required: false,
}}
/>
</CardContent>
</Collapse>
</Card>
</>
)
}
export { ReserveItemAdditional }

View File

@@ -0,0 +1,352 @@
import {
ControlledDateRangePicker,
ControlledRadioField,
ControlledSwitchField,
ControlledTextField,
} from '@components/ControlledField'
import Box from '@material-ui/core/Box'
import Card from '@material-ui/core/Card'
import CardActions from '@material-ui/core/CardActions'
import CardContent from '@material-ui/core/CardContent'
import CardHeader from '@material-ui/core/CardHeader'
import Collapse from '@material-ui/core/Collapse'
import Divider from '@material-ui/core/Divider'
import FormHelperText from '@material-ui/core/FormHelperText'
import Grid from '@material-ui/core/Grid'
import IconButton from '@material-ui/core/IconButton'
import MenuItem from '@material-ui/core/MenuItem'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Switch from '@material-ui/core/Switch'
import Typography from '@material-ui/core/Typography'
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
import { ICode, ILocation, IReserveItem, ReserveItemFormProps } from '@service'
import React, { useEffect, useState } from 'react'
import { Controller, useWatch } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { ReserveItemMethod } from './ReserveItemMethod'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginBottom: theme.spacing(2),
},
header: {
justifyContent: 'space-between',
},
help: {
marginLeft: theme.spacing(2),
},
container: {
display: 'flex',
flexDirection: 'column',
},
switch: {
width: '100%',
justifyContent: 'start',
border: '1px solid rgba(0, 0, 0, 0.23)',
borderRadius: theme.spacing(0.5),
padding: theme.spacing(1),
margin: theme.spacing(1, 0),
},
}),
)
export interface ReserveItemBasicProps extends ReserveItemFormProps {
data: IReserveItem
locations: ILocation[]
categories: ICode[]
reserveMethods: ICode[]
reserveMeans: ICode[]
selectionMeans: ICode[]
}
const containKeys: string[] = [
'locationId',
'categoryId',
'reserveItemId',
'totalQty',
'operationStartDate',
'operationEndDate',
'reserveMethodId',
'reserveMeansId',
'requestStartDate',
'requestEndDate',
'isPeriod',
'periodMaxCount',
'externalUrl',
'selectionMeansId',
'isFree',
'usageCost',
'isUse',
]
const ReserveItemBasic = (props: ReserveItemBasicProps) => {
const {
getValues,
control,
formState,
data,
locations,
categories,
reserveMethods,
reserveMeans,
selectionMeans,
} = props
const classes = useStyles()
const { t } = useTranslation()
const [expanded, setExpanded] = useState<boolean>(true)
const [errorText, setErrorText] = useState<string | undefined>(undefined)
const [openMethod, setOpenMethod] = useState<boolean>(false)
const [openCost, setOpenCost] = useState<boolean>(false)
const watchReserveMethod = useWatch({
control,
name: 'reserveMethodId',
})
const watchFree = useWatch({
control,
name: 'isPaid',
})
useEffect(() => {
if (formState.errors) {
const keys = Object.keys(formState.errors)
const found = keys.some(r => containKeys.includes(r))
if (keys.length > 0 && found) {
setErrorText('입력값이 잘못 되었습니다.')
} else {
setErrorText(undefined)
}
}
}, [formState.errors])
useEffect(() => {
if (watchReserveMethod === 'internet') {
setOpenMethod(true)
} else {
setOpenMethod(false)
}
}, [watchReserveMethod])
useEffect(() => {
setOpenCost(watchFree)
}, [watchFree])
const handleExpandClick = () => {
setExpanded(!expanded)
}
return (
<>
<Card className={classes.root}>
<CardActions className={classes.header}>
<CardHeader
title={`${t('common.basic')} ${t('common.information')}`}
subheader={errorText && errorText}
subheaderTypographyProps={{
color: 'error',
}}
/>
<IconButton onClick={handleExpandClick}>
<ExpandMoreIcon />
</IconButton>
</CardActions>
<Divider />
<Collapse in={expanded} timeout="auto" unmountOnExit>
<CardContent className={classes.container}>
<ControlledTextField
control={control}
formState={formState}
name="locationId"
label={t('location')}
isSelect={true}
defaultValue={1}
textFieldProps={{
required: true,
}}
>
{locations.map(value => (
<MenuItem
key={`location-${value.locationId}`}
value={value.locationId}
>
{value.locationName}
</MenuItem>
))}
</ControlledTextField>
<ControlledTextField
control={control}
formState={formState}
name="categoryId"
label={t('reserve_item.type')}
isSelect={true}
defaultValue={''}
textFieldProps={{
required: true,
}}
>
{categories.map(value => (
<MenuItem key={`category-${value.codeId}`} value={value.codeId}>
{value.codeName}
</MenuItem>
))}
</ControlledTextField>
<ControlledTextField
control={control}
formState={formState}
name="reserveItemName"
label={t('reserve_item.name')}
defaultValue={''}
textFieldProps={{
required: true,
}}
/>
<ControlledTextField
control={control}
formState={formState}
name="totalQty"
label={`${t('reserve.count')}/${t('reserve.number_of_people')}`}
defaultValue={''}
textFieldProps={{
fullWidth: false,
required: true,
}}
help={
<FormHelperText className={classes.help}>
* {t('reserve_titem.msg.help_period')}
</FormHelperText>
}
/>
<ControlledDateRangePicker
getValues={getValues}
control={control}
formState={formState}
required={true}
startProps={{
label: `${t('reserve_item.operation')} ${t(
'common.start_date',
)}`,
name: 'operationStartDate',
contollerProps: {
rules: {
required: true,
},
},
}}
endProps={{
label: `${t('reserve_item.operation')} ${t('common.end_date')}`,
name: 'operationEndDate',
contollerProps: {
rules: {
required: true,
},
},
}}
/>
<ControlledRadioField
control={control}
formState={formState}
name="reserveMethodId"
label={t('reserve_item.reserve_method')}
defaultValue={''}
requried={true}
data={{
idkey: 'codeId',
namekey: 'codeName',
data: reserveMethods,
}}
/>
{openMethod && (
<ReserveItemMethod
control={control}
formState={formState}
getValues={getValues}
reserveMeans={reserveMeans}
/>
)}
<ControlledRadioField
control={control}
formState={formState}
name="selectionMeansId"
label={t('reserve_item.selection_means')}
defaultValue={''}
requried
data={{
idkey: 'codeId',
namekey: 'codeName',
data: selectionMeans,
}}
/>
<Box className={classes.switch}>
<Typography component="div">
<Grid
component="label"
container
alignItems="center"
spacing={1}
>
<Grid item>{t('common.free')}</Grid>
<Grid item>
<Controller
name="isPaid"
control={control}
defaultValue={false}
render={({ field: { onChange, ref, value } }) => (
<Switch
inputProps={{ 'aria-label': 'secondary checkbox' }}
onChange={onChange}
inputRef={ref}
checked={value}
/>
)}
/>
</Grid>
<Grid item>{t('common.paid')}</Grid>
</Grid>
</Typography>
</Box>
{openCost && (
<ControlledTextField
control={control}
formState={formState}
name="usageCost"
label={t('reserve_item.usage_fee')}
defaultValue={0}
textFieldProps={{
required: true,
type: 'number',
}}
contollerProps={{
rules: {
required: true,
pattern: {
value: /^[0-9]*$/,
message: t('valid.valueAsNumber'),
},
},
}}
/>
)}
<ControlledSwitchField
control={control}
formState={formState}
label={t('common.use_at')}
name="isUse"
contollerProps={{
defaultValue: false,
}}
/>
</CardContent>
</Collapse>
</Card>
</>
)
}
export { ReserveItemBasic }

View File

@@ -0,0 +1,131 @@
import { ControlledTextField } from '@components/ControlledField'
import Card from '@material-ui/core/Card'
import CardActions from '@material-ui/core/CardActions'
import CardContent from '@material-ui/core/CardContent'
import CardHeader from '@material-ui/core/CardHeader'
import Collapse from '@material-ui/core/Collapse'
import Divider from '@material-ui/core/Divider'
import IconButton from '@material-ui/core/IconButton'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
import { ReserveItemFormProps } from '@service'
import { useTranslation } from 'next-i18next'
import React, { useEffect, useState } from 'react'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginBottom: theme.spacing(2),
'& .MuiInputLabel-outlined': {
zIndex: 0,
},
},
header: {
justifyContent: 'space-between',
},
container: {
display: 'flex',
flexDirection: 'column',
},
}),
)
const containKeys: string[] = ['managerDept', 'managerName', 'managerContact']
interface ReserveItemManagerProps extends ReserveItemFormProps {}
const ReserveItemManager = (props: ReserveItemManagerProps) => {
const { control, formState } = props
const classes = useStyles()
const { t } = useTranslation()
const [expanded, setExpanded] = useState<boolean>(true)
const [errorText, setErrorText] = useState<string | undefined>(undefined)
useEffect(() => {
if (formState.errors) {
const keys = Object.keys(formState.errors)
const found = keys.some(r => containKeys.includes(r))
if (keys.length > 0 && found) {
setErrorText('입력값이 잘못 되었습니다.')
} else {
setErrorText(undefined)
}
}
}, [formState.errors])
const handleExpandClick = () => {
setExpanded(!expanded)
}
return (
<>
<Card className={classes.root}>
<CardActions className={classes.header}>
<CardHeader
title={`${t('reserve_item.manager')} ${t('common.information')}`}
subheader={errorText && errorText}
subheaderTypographyProps={{
color: 'error',
}}
/>
<IconButton onClick={handleExpandClick}>
<ExpandMoreIcon />
</IconButton>
</CardActions>
<Divider />
<Collapse in={expanded} timeout="auto" unmountOnExit>
<CardContent className={classes.container}>
<ControlledTextField
control={control}
formState={formState}
name="managerDept"
label={`${t('reserve_item.manager')} ${t('reserve_item.dept')}`}
defaultValue={''}
contollerProps={{
rules: {
maxLength: 200,
},
}}
textFieldProps={{
required: false,
}}
/>
<ControlledTextField
control={control}
formState={formState}
name="managerName"
label={`${t('reserve_item.manager')} ${t('label.title.name')}`}
defaultValue={''}
contollerProps={{
rules: {
maxLength: 200,
},
}}
textFieldProps={{
required: false,
}}
/>
<ControlledTextField
control={control}
formState={formState}
name="managerContact"
label={`${t('reserve_item.manager')} ${t('common.contact')}`}
defaultValue={''}
contollerProps={{
rules: {
maxLength: 50,
},
}}
textFieldProps={{
required: false,
}}
/>
</CardContent>
</Collapse>
</Card>
</>
)
}
export { ReserveItemManager }

View File

@@ -0,0 +1,146 @@
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next'
import { useWatch } from 'react-hook-form'
import { ICode, ReserveItemFormProps } from '@service'
import {
ControlledDateRangePicker,
ControlledRadioField,
ControlledSwitchField,
ControlledTextField,
} from '@components/ControlledField'
interface ReserveItemMethodProps extends ReserveItemFormProps {
reserveMeans: ICode[]
}
const ReserveItemMethod = (props: ReserveItemMethodProps) => {
const { control, formState, getValues, reserveMeans } = props
const { t } = useTranslation()
const [isExternal, setIsExternal] = useState<boolean | null>(null)
const watchReserveMeans = useWatch({
control,
name: 'reserveMeansId',
})
const [open, setOpen] = useState<boolean>(false)
const watchPeriod = useWatch({
control,
name: 'isPeriod',
})
useEffect(() => {
setOpen(watchPeriod)
}, [watchPeriod])
useEffect(() => {
if (watchReserveMeans === 'external') {
setIsExternal(true)
} else {
setIsExternal(false)
}
}, [watchReserveMeans])
return (
<>
<ControlledRadioField
control={control}
formState={formState}
name="reserveMeansId"
label={'인터넷 예약 구분'}
defaultValue={''}
requried
data={{
idkey: 'codeId',
namekey: 'codeName',
data: reserveMeans,
}}
/>
{Boolean(isExternal) === false && (
<>
<ControlledDateRangePicker
getValues={getValues}
control={control}
formState={formState}
required={true}
format="yyyy-MM-dd HH:mm"
startProps={{
label: '예약신청시작일시',
name: 'requestStartDate',
contollerProps: {
rules: {
required: true,
},
},
showTimeSelect: true,
}}
endProps={{
label: '예약신청종료일시',
name: 'requestEndDate',
contollerProps: {
rules: {
required: true,
},
},
showTimeSelect: true,
}}
/>
<ControlledSwitchField
control={control}
formState={formState}
label={'기간 지정 가능여부'}
name="isPeriod"
contollerProps={{
defaultValue: false,
}}
/>
{open && (
<ControlledTextField
control={control}
formState={formState}
name="periodMaxCount"
label={'최대예약가능일수'}
defaultValue={0}
textFieldProps={{
required: true,
type: 'number',
}}
contollerProps={{
rules: {
required: true,
maxLength: 3,
pattern: {
value: /^[0-9]*$/,
message: t('valid.valueAsNumber'),
},
},
}}
/>
)}
</>
)}
{isExternal && (
<ControlledTextField
control={control}
formState={formState}
name="externalUrl"
label={'외부링크URL'}
defaultValue={''}
textFieldProps={{
required: true,
}}
contollerProps={{
rules: {
required: true,
maxLength: 500,
},
}}
/>
)}
</>
)
}
export { ReserveItemMethod }

View File

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

View File

@@ -0,0 +1,187 @@
import React, { createRef, useEffect, useState } from 'react'
import { makeStyles, createStyles, Theme } from '@material-ui/core/styles'
import Box from '@material-ui/core/Box'
import TextField from '@material-ui/core/TextField'
import MenuItem from '@material-ui/core/MenuItem'
import IconButton from '@material-ui/core/IconButton'
import SearchIcon from '@material-ui/icons/Search'
import Fab from '@material-ui/core/Fab'
import AddIcon from '@material-ui/icons/Add'
import { conditionAtom, conditionSelector, conditionValue } from '@stores'
import { useRecoilValue, useSetRecoilState } from 'recoil'
// styles
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
'& .MuiOutlinedInput-input': {
padding: theme.spacing(1),
},
},
search: {
padding: theme.spacing(1),
textAlign: 'center',
},
select: {
padding: theme.spacing(1),
textAlign: 'center',
width: '15vw',
minWidth: 80,
maxWidth: 150,
},
iconButton: {
padding: theme.spacing(1),
marginLeft: theme.spacing(1),
backgroundColor: theme.palette.background.default,
},
fab: {
marginLeft: theme.spacing(1),
},
}),
)
// 조회조건 타입
export interface ICondition {
keywordType: string
keyword: string
}
// 조회조건 select 아이템 타입
export interface IKeywordType {
key: string
label: string
}
// 조회조건 컴포넌트 props
export interface ISearchProp {
keywordTypeItems: IKeywordType[] // 조회조건 select items
handleSearch: () => void // 조회 시
handleRegister?: () => void // 등록 시
conditionKey: string // 조회조건 상태값을 관리할 키 값 (e.g. 이용약관관리 -> policy)
isNotWrapper?: boolean
customKeyword?: conditionValue
conditionNodes?: React.ReactNode
}
const Search = (props: ISearchProp) => {
const {
keywordTypeItems,
handleSearch,
handleRegister,
conditionKey,
customKeyword,
isNotWrapper,
conditionNodes,
} = props
const classes = useStyles()
// 조회조건에 대한 키(conditionKey)로 각 기능에서 조회조건 상태값을 관리한다.
const setValue = useSetRecoilState(conditionSelector(conditionKey))
const conditionState = useRecoilValue(conditionAtom(conditionKey))
const [keywordTypeState, setKeywordTypeState] = useState<string>('')
const inputRef = createRef<HTMLInputElement>()
useEffect(() => {
if (conditionState) {
setKeywordTypeState(conditionState.keywordType)
return
}
if (keywordTypeItems.length > 0) {
setKeywordTypeState(keywordTypeItems[0].key)
return
}
}, [conditionState, keywordTypeItems])
// 조회 시 조회조건 상태값 저장 후 부모컴포넌트의 조회 함수를 call한다.
const search = () => {
setValue({
...conditionState,
keywordType: keywordTypeState,
keyword: inputRef.current?.value,
...customKeyword,
})
handleSearch()
}
// 조회조건 select onchange
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setKeywordTypeState(event.target.value)
}
// 조회조건 input에서 enter키 눌렀을 경우 조회
const onKeyPress = (event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
search()
}
}
// 조회 버튼 클릭
const onClickSearch = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
search()
}
// 등록 버튼 클릭
const onClickAdd = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
handleRegister()
}
return (
<div className={classes.root}>
<Box display="flex" flexDirection="row" justifyContent="flex-end">
{conditionNodes && conditionNodes}
<Box className={classes.select}>
<TextField
id="filled-select-currency"
select
value={keywordTypeState}
onChange={onChange}
variant="outlined"
fullWidth
>
{keywordTypeItems.map(option => (
<MenuItem key={option.key} value={option.key}>
{option.label}
</MenuItem>
))}
</TextField>
</Box>
<Box width="auto" className={classes.search}>
<TextField
inputRef={inputRef}
placeholder="Search..."
inputProps={{ 'aria-label': 'search' }}
variant="outlined"
onKeyPress={onKeyPress}
defaultValue={conditionState ? conditionState.keyword : ''}
/>
<IconButton
className={classes.iconButton}
aria-label="search"
color="primary"
onClick={onClickSearch}
>
<SearchIcon />
</IconButton>
{handleRegister && (
<Fab
color="primary"
aria-label="add"
className={classes.fab}
size="small"
onClick={onClickAdd}
>
<AddIcon />
</Fab>
)}
</Box>
</Box>
</div>
)
}
export default Search

View File

@@ -0,0 +1,44 @@
import {
GRID_PAGE_SIZE,
GRID_ROWS_PER_PAGE_OPTION,
GRID_ROW_HEIGHT,
} from '@constants'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import { DataGrid, DataGridProps } from '@material-ui/data-grid'
import * as React from 'react'
import DataGridPagination from './DataGridPagination'
export interface IDataGridProps extends DataGridProps {}
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
'& .hover': {
cursor: 'pointer',
color: '#1a3e72',
},
},
}),
)
export default function CustomDataGrid(props: IDataGridProps) {
const { columns, rows, pageSize, rowsPerPageOptions, rowHeight, getRowId } =
props
const classes = useStyles()
return (
<div className={classes.root}>
<DataGrid
{...props}
rows={rows || []}
columns={columns}
rowHeight={rowHeight || GRID_ROW_HEIGHT}
pageSize={pageSize || GRID_PAGE_SIZE}
rowsPerPageOptions={rowsPerPageOptions || GRID_ROWS_PER_PAGE_OPTION}
autoHeight
pagination
components={{ Pagination: DataGridPagination }}
getRowId={getRowId || (r => r.id)}
/>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import React from 'react'
import { useGridSlotComponentProps } from '@material-ui/data-grid'
import Pagination, { PaginationProps } from '@material-ui/lab/Pagination'
import PaginationItem from '@material-ui/lab/PaginationItem'
export default function DataGridPagination(props: PaginationProps) {
const { state, apiRef } = useGridSlotComponentProps()
return (
<Pagination
color="primary"
variant="outlined"
shape="rounded"
page={state.pagination.page + 1}
count={state.pagination.pageCount}
showFirstButton={true}
showLastButton={true}
// @ts-expect-error
renderItem={item => <PaginationItem {...item} disableRipple />}
onChange={(event, value) => apiRef.current.setPage(value - 1)}
{...props}
/>
)
}

View File

@@ -0,0 +1,90 @@
import React from 'react'
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'
import IconButton from '@material-ui/core/IconButton'
import FirstPageIcon from '@material-ui/icons/FirstPage'
import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft'
import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight'
import LastPageIcon from '@material-ui/icons/LastPage'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexShrink: 0,
marginLeft: theme.spacing(2.5),
},
}),
)
interface TablePaginationActionsProps {
count: number
page: number
rowsPerPage: number
onChangePage: (
event: React.MouseEvent<HTMLButtonElement>,
newPage: number,
) => void
}
export default function TablePaginationActions(
props: TablePaginationActionsProps,
) {
const classes = useStyles()
const { count, page, rowsPerPage, onChangePage } = props
const handleFirstPageButtonClick = (
event: React.MouseEvent<HTMLButtonElement>,
) => {
onChangePage(event, 0)
}
const handleBackButtonClick = (
event: React.MouseEvent<HTMLButtonElement>,
) => {
onChangePage(event, page - 1)
}
const handleNextButtonClick = (
event: React.MouseEvent<HTMLButtonElement>,
) => {
onChangePage(event, page + 1)
}
const handleLastPageButtonClick = (
event: React.MouseEvent<HTMLButtonElement>,
) => {
onChangePage(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1))
}
return (
<div className={classes.root}>
<IconButton
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
<FirstPageIcon />
</IconButton>
<IconButton
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
<KeyboardArrowLeft />
</IconButton>
<IconButton
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
<KeyboardArrowRight />
</IconButton>
<IconButton
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
<LastPageIcon />
</IconButton>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import Paper from '@material-ui/core/Paper'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import Tabs, { TabsProps } from '@material-ui/core/Tabs'
import React, { useState } from 'react'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
background: theme.palette.background.default,
},
}),
)
interface HorizontalTabsProps extends TabsProps {
tabs: React.ReactNode
init: string | number
handleTab: (value: string | number) => void
}
const HorizontalTabs = (props: HorizontalTabsProps) => {
const { tabs, init, handleTab, ...rest } = props
const classes = useStyles()
const [value, setValue] = useState<string | number>(init)
const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => {
handleTab(newValue)
setValue(newValue)
}
return (
<Paper className={classes.root}>
<Tabs
value={value}
onChange={handleChange}
indicatorColor="primary"
textColor="primary"
{...rest}
>
{tabs}
</Tabs>
</Paper>
)
}
export { HorizontalTabs }

View File

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

View File

@@ -0,0 +1,116 @@
import React, { useContext, useEffect, useState } from 'react'
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'
import Grid from '@material-ui/core/Grid'
import List from '@material-ui/core/List'
import ListItem from '@material-ui/core/ListItem'
import ListItemAvatar from '@material-ui/core/ListItemAvatar'
import Avatar from '@material-ui/core/Avatar'
import FolderIcon from '@material-ui/icons/Folder'
import ListItemText from '@material-ui/core/ListItemText'
import { IFile } from '@service'
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
import IconButton from '@material-ui/core/IconButton'
import DeleteIcon from '@material-ui/icons/Delete'
import { formatBytes } from '@utils'
import produce from 'immer'
import { FileContext } from '.'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
marginTop: '1px',
padding: 0,
},
list: {
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(0),
},
item: {
padding: theme.spacing(1, 6, 1, 1),
},
pd0: {
padding: theme.spacing(0),
},
}),
)
interface IFileList {
key: string
name: string
size: number
}
const FileList = () => {
const classes = useStyles()
const { selectedFiles, setSelectedFilesHandler } = useContext(FileContext)
const [fileList, setFileList] = useState<IFileList[]>([])
useEffect(() => {
let list: IFileList[] = []
for (const key in selectedFiles) {
if (Object.prototype.hasOwnProperty.call(selectedFiles, key)) {
const item = selectedFiles[key]
list.push({
key: item.key,
name: item.file.name,
size: item.file.size,
})
}
}
setFileList(list)
}, [selectedFiles])
const handleDelete = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
key: string,
) => {
event.preventDefault()
const index = selectedFiles.findIndex(item => item.key === key)
const newFiles: IFile[] = produce(selectedFiles, draft => {
draft.splice(index, 1)
})
setSelectedFilesHandler(newFiles)
}
return (
<div className={classes.root}>
<Grid container>
<Grid item>
<div>
{fileList && (
<List className={classes.list}>
{fileList.map(item => (
<ListItem key={item.key} className={classes.item}>
<ListItemAvatar>
<Avatar>
<FolderIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={item.name}
secondary={formatBytes(item.size)}
/>
<ListItemSecondaryAction
onClick={event => handleDelete(event, item.key)}
>
<IconButton edge="end" aria-label="delete">
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
</div>
</Grid>
</Grid>
</div>
)
}
export default FileList

View File

@@ -0,0 +1,97 @@
import React, { useContext, useEffect, useState } from 'react'
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'
import Divider from '@material-ui/core/Divider'
import InputBase from '@material-ui/core/InputBase'
import Paper from '@material-ui/core/Paper'
import AttachFileIcon from '@material-ui/icons/AttachFile'
import Button from '@material-ui/core/Button'
import { DEFAULT_ACCEPT_FILE_EXT } from '@constants'
import { IFile } from '@service'
import { FileContext, UploadProps } from '.'
import { useTranslation } from 'react-i18next'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
padding: theme.spacing(1),
display: 'flex',
alignItems: 'center',
},
input: {
marginLeft: theme.spacing(1),
flex: 1,
},
iconButton: {
padding: 10,
},
divider: {
height: 28,
margin: 4,
},
fileInput: {
display: 'none',
},
}),
)
const FileUpload = (props: UploadProps) => {
const { accept, multi } = props
const classes = useStyles()
const { t } = useTranslation()
const { selectedFiles, setSelectedFilesHandler } = useContext(FileContext)
const [fileCnt, setFileCnt] = useState<number>(0)
useEffect(() => {
setFileCnt(selectedFiles?.length || 0)
}, [selectedFiles])
const handleChangeFiles = (event: React.ChangeEvent<HTMLInputElement>) => {
const fileList = event.target.files
let newSelectedFiles: IFile[] = []
for (const key in fileList) {
if (Object.prototype.hasOwnProperty.call(fileList, key)) {
const item = fileList[key]
newSelectedFiles.push({
key: `${Math.random().toString(36).substr(2, 11)}`,
file: item,
})
}
}
if (selectedFiles !== undefined) {
newSelectedFiles = newSelectedFiles.concat(selectedFiles)
}
setSelectedFilesHandler(newSelectedFiles)
}
return (
<Paper component="form" className={classes.root}>
<InputBase
className={classes.input}
placeholder={t('file.placeholder')}
inputProps={{ 'aria-label': 'add attachments' }}
value={fileCnt === 0 ? '' : `${fileCnt} 개의 파일이 선택되었습니다.`}
/>
<Divider className={classes.divider} orientation="vertical" />
<input
accept={accept || DEFAULT_ACCEPT_FILE_EXT}
className={classes.fileInput}
id="contained-button-file"
onChange={handleChangeFiles}
multiple={multi}
type="file"
/>
<label htmlFor="contained-button-file">
<Button variant="contained" color="primary" component="span">
<AttachFileIcon /> {t('file.search')}
</Button>
</label>
</Paper>
)
}
export default FileUpload

View File

@@ -0,0 +1,224 @@
import CustomAlert from '@components/CustomAlert'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import {
AttachmentSavePayload,
fileService,
IAttachmentResponse,
IFile,
UploadInfoReqeust,
} from '@service'
import { format, formatBytes } from '@utils'
import { useTranslation } from 'next-i18next'
import React, {
createContext,
forwardRef,
useImperativeHandle,
useState,
} from 'react'
import FileList from './FileList'
import FileUpload from './FileUpload'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
listStyle: 'none',
position: 'unset',
},
}),
)
export type UploadType = {
isModified: (list?: IAttachmentResponse[]) => Promise<boolean>
count: (list?: IAttachmentResponse[]) => Promise<number>
upload: (
info?: UploadInfoReqeust,
list?: IAttachmentResponse[],
) => Promise<string>
rollback: (attachmentCode: string) => void
}
export interface UploadProps {
accept?: string
multi?: boolean
uploadLimitCount?: number
uploadLimitSize?: number
attachmentCode?: string
attachData?: IAttachmentResponse[]
}
export const FileContext = createContext<{
selectedFiles: IFile[]
setSelectedFilesHandler: (files: IFile[]) => void
}>({
selectedFiles: undefined,
setSelectedFilesHandler: () => {},
})
const Upload = forwardRef<UploadType, UploadProps>((props, ref) => {
const { attachmentCode, attachData, uploadLimitCount, uploadLimitSize } =
props
const classes = useStyles()
const { t } = useTranslation()
// alert
const [customAlert, setCustomAlert] = useState<{
open: boolean
contentText: string
}>({
open: false,
contentText: '',
})
const [spare, setSpare] = useState<IFile[]>(undefined)
const [selectedFiles, setSelectedFiles] = useState<IFile[]>(undefined)
const setSelectedFilesHandler = (files: IFile[]) => {
// 파일 수 체크
const uploadCount =
(attachData ? attachData.filter(file => !file.isDelete).length : 0) +
files.length
if (uploadLimitCount && uploadCount > uploadLimitCount) {
setCustomAlert({
open: true,
contentText: format(t('valid.upload_limit_count.format'), [
uploadLimitCount,
]),
})
return
}
// 용량 체크
if (uploadLimitCount) {
const uploadSize = files.reduce(
(accumulator, currentValue) => accumulator + currentValue.file.size,
0,
)
if (uploadSize > uploadLimitSize) {
setCustomAlert({
open: true,
contentText: format(t('valid.upload_limit_size.format'), [
`${formatBytes(uploadLimitSize, 0)}`,
]),
})
return
}
}
setSelectedFiles(files)
}
useImperativeHandle(ref, () => ({
isModified: (list?: IAttachmentResponse[]) =>
new Promise<boolean>(resolve => {
if (selectedFiles?.length > 0) {
resolve(true)
}
if (list?.filter(m => m.isDelete).length > 0) {
resolve(true)
}
resolve(false)
}),
count: (list?: IAttachmentResponse[]) =>
new Promise<number>(resolve => {
resolve(
(selectedFiles?.length ? selectedFiles?.length : 0) +
(list ? list.filter(m => !m.isDelete).length : 0),
)
}),
upload: (info?: UploadInfoReqeust, list?: IAttachmentResponse[]) =>
new Promise<string>((resolve, reject) => {
if (selectedFiles) {
let saveList: AttachmentSavePayload[] = []
if (list && list.length > 0) {
list.map(item => {
if (item.isDelete) {
saveList.push({
uniqueId: item.id,
isDelete: item.isDelete,
})
}
})
}
setSpare(selectedFiles)
fileService
.upload({
fileList: selectedFiles,
attachmentCode: attachmentCode,
info,
list: saveList,
})
.then(response => {
setSelectedFiles(undefined)
resolve(response.data)
})
.catch(error => {
setSelectedFiles(undefined)
reject(error)
})
} else if (list) {
let saveList: AttachmentSavePayload[] = []
list.map(item => {
if (item.isDelete) {
saveList.push({
uniqueId: item.id,
isDelete: item.isDelete,
})
}
})
if (saveList.length <= 0) {
resolve('no update list')
return
}
fileService
.save({
attachmentCode: attachmentCode,
info,
list: saveList,
})
.then(response => {
resolve(response.data)
})
.catch(error => {
reject(error)
})
} else {
resolve('no attachments')
}
}),
rollback: async (attachmentCode: string) => {
try {
await fileService.deleteAll(attachmentCode)
if (spare) {
setSelectedFiles(spare)
setSpare(undefined)
}
} catch (error) {
console.error(`file rollback error : ${error.message}`)
}
},
}))
const handleAlert = () => {
setCustomAlert({
...customAlert,
open: false,
})
}
return (
<div className={classes.root}>
<FileContext.Provider value={{ selectedFiles, setSelectedFilesHandler }}>
<FileUpload {...props} />
<FileList />
</FileContext.Provider>
<CustomAlert handleAlert={handleAlert} {...customAlert} />
</div>
)
})
export { Upload }

View File

@@ -0,0 +1,89 @@
import React, { useEffect, useState } from 'react'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import { useSnackbar } from 'notistack'
import { errorStateAtom } from '@stores'
import { useRecoilState } from 'recoil'
import CustomAlert from '@components/CustomAlert'
import { ButtonProps } from '@material-ui/core/Button'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
},
paper: {
display: 'flex',
margin: theme.spacing(1),
},
}),
)
const customAlertButtonProps: ButtonProps = {
variant: 'outlined',
color: 'secondary',
}
const GlobalError = () => {
const [errorState, setErrorState] = useRecoilState(errorStateAtom)
const { enqueueSnackbar } = useSnackbar()
const [alertState, setAlertState] = useState<{
open: boolean
errors: string[]
}>({
open: false,
errors: [],
})
const classes = useStyles()
useEffect(() => {
if (errorState.error) {
if (errorState.status === 400) {
const errors = errorState.errors.map(item => {
return item.defaultMessage
})
setAlertState({
open: true,
errors,
})
} else {
enqueueSnackbar(errorState.message, {
variant: 'error',
onClose: resetError,
})
}
}
}, [errorState])
if (!errorState.error) return null
const resetError = () => {
setAlertState({
open: false,
errors: [],
})
setErrorState({
open: false,
error: null,
message: '',
status: null,
errors: null,
})
}
return (
<>
<CustomAlert
open={alertState.open}
handleAlert={resetError}
title={errorState.message}
contentText={alertState.errors}
severity="error"
classes={classes}
buttonProps={customAlertButtonProps}
/>
</>
)
}
export default GlobalError

View File

@@ -0,0 +1,14 @@
import useMounted from '@hooks/useMounted'
import React, { Suspense, SuspenseProps } from 'react'
const SSRSafeSuspense = (props: SuspenseProps) => {
const isMounted = useMounted()
if (isMounted) {
return <Suspense {...props} />
}
return <>{props.fallback}</>
}
export default SSRSafeSuspense

View File

@@ -0,0 +1,19 @@
import React from 'react'
import Loader from '@components/Loader'
import GlobalError from './GlobalError'
import SSRSafeSuspense from './SSRSafeSuspense'
export interface IWrapperProps {
children: React.ReactNode
}
const Wrapper = ({ children }: IWrapperProps) => {
return (
<>
<SSRSafeSuspense fallback={<Loader />}>{children}</SSRSafeSuspense>
<GlobalError />
</>
)
}
export default Wrapper

View File

@@ -0,0 +1,211 @@
import React, { useState } from 'react'
import { Controller, FormProvider, useForm } from 'react-hook-form'
import { useTranslation } from 'next-i18next'
import {
createStyles,
makeStyles,
Theme,
useTheme,
} from '@material-ui/core/styles'
import Box from '@material-ui/core/Box'
import TextField from '@material-ui/core/TextField'
import Button from '@material-ui/core/Button'
import useUser from '@hooks/useUser'
import { IComment } from '@service'
import { format } from '@utils'
import CustomAlert from '@components/CustomAlert'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
commentCreator: {
padding: theme.spacing(1, 1, 1, 0),
fontWeight: 500,
},
commentContent: {
padding: theme.spacing(0),
},
commentContentInput: {
padding: '0 !important',
},
commentButtons: {
margin: theme.spacing(1, 0, 0, 0),
padding: theme.spacing(0),
},
ml1: {
marginLeft: theme.spacing(1),
},
}),
)
export interface ICommentFormProps {
boardNo: number
postsNo: number
commentNo?: number
commentContent?: string
groupNo?: number
parentCommentNo?: number
depthSeq?: number
handleCommentSave: (comment: ICommentFormInput) => void
handleCommentCancel?: (comment: ICommentFormInput) => void
}
interface ICommentFormInput {
parentCommentNo?: number
commentContent: string
}
const CommentForm: React.FC<ICommentFormProps> = ({
boardNo,
postsNo,
commentNo,
commentContent,
groupNo,
parentCommentNo,
depthSeq,
handleCommentSave,
handleCommentCancel,
}) => {
const classes = useStyles()
const { user } = useUser()
const { t } = useTranslation()
const theme = useTheme()
// alert
const [customAlert, setCustomAlert] = useState<any>({
open: false,
message: '',
handleAlert: () => setCustomAlert({ open: false }),
})
// form hook
const methods = useForm<ICommentFormInput>({
defaultValues: {
commentContent,
},
})
const { control, handleSubmit, setValue, setFocus } = methods
const saveComment = async (formData: ICommentFormInput) => {
if (!formData.commentContent) {
setCustomAlert({
open: true,
message: format(t('valid.required.format'), [
t('comment.comment_content'),
]),
handleAlert: () => {
setCustomAlert({
open: false,
})
setFocus('commentContent') // TODO 작동안함..
},
})
return
}
const comment: IComment = {
boardNo,
postsNo,
commentNo,
commentContent: formData.commentContent,
groupNo,
parentCommentNo,
depthSeq: typeof depthSeq === 'undefined' ? 0 : depthSeq,
}
handleCommentSave(comment)
if (!parentCommentNo && typeof commentNo === 'undefined') {
setValue('commentContent', '')
}
}
const handleCancel = () => {
const comment: IComment = {
boardNo,
postsNo,
commentNo,
commentContent,
groupNo,
parentCommentNo,
depthSeq,
}
if (handleCommentCancel) {
handleCommentCancel(comment)
}
// setValue('commentContent', '')
}
return (
<FormProvider {...methods}>
<form
style={{
paddingLeft: `${theme.spacing(1) + depthSeq * theme.spacing(4)}px`,
}}
>
<Box className={classes.commentCreator}>{user.userName}</Box>
<Controller
name="commentContent"
control={control}
rules={{ maxLength: 2000 }}
render={({ field }) => (
<TextField
label={t('comment.comment_content')}
className={classes.commentContent}
multiline
minRows={1}
inputProps={{
maxLength: 2000,
className: classes.commentContentInput,
}}
id="outlined-full-width"
placeholder={format(t('msg.placeholder.format'), [
t('comment.comment_content'),
])}
fullWidth
variant="outlined"
{...field}
/>
)}
/>
<Box
className={classes.commentButtons}
display="flex"
justifyContent="flex-end"
m={1}
p={1}
>
<Button
variant="contained"
color="primary"
size="small"
onClick={handleSubmit(saveComment)}
>
{commentNo || parentCommentNo
? t('label.button.save')
: t('label.button.reg')}
</Button>
{(commentNo || parentCommentNo) && (
<Button
className={classes.ml1}
variant="contained"
color="default"
size="small"
onClick={handleCancel}
>
{t('label.button.cancel')}
</Button>
)}
</Box>
</form>
<CustomAlert
contentText={customAlert.message}
open={customAlert.open}
handleAlert={customAlert.handleAlert}
/>
</FormProvider>
)
}
export { CommentForm }

View File

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

View File

@@ -0,0 +1,488 @@
import React, { useCallback, useEffect, useState } from 'react'
import { AxiosError } from 'axios'
import { useSetRecoilState } from 'recoil'
import { useTranslation } from 'next-i18next'
import classNames from 'classnames'
import {
createStyles,
makeStyles,
Theme,
useTheme,
} from '@material-ui/core/styles'
import Box from '@material-ui/core/Box'
import Card from '@material-ui/core/Card'
import CardActions from '@material-ui/core/CardActions'
import CardContent from '@material-ui/core/CardContent'
import Typography from '@material-ui/core/Typography'
import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline'
import RefreshIcon from '@material-ui/icons/Refresh'
import Link from '@material-ui/core/Link'
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
import Button from '@material-ui/core/Button'
import { detailButtonsSnackAtom, errorStateSelector } from '@stores'
import useUser from '@hooks/useUser'
import { commentService, IComment } from '@service'
import { convertStringToDateFormat } from '@libs/date'
import { ConfirmDialog, ConfirmDialogProps } from '@components/Confirm'
import { CustomButtons } from '@components/Buttons'
import { CommentForm } from './form'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
commentRoot: {
marginTop: theme.spacing(1),
padding: theme.spacing(0, 2, 1, 2),
},
commentBox: {
padding: theme.spacing(2, 2, 1, 2),
},
commentTitle: {
display: 'flex',
padding: theme.spacing(2, 1),
},
commentView: {
padding: theme.spacing(0),
},
commentIcon: {
marginRight: theme.spacing(0.5),
verticalAlign: 'middle',
},
commentContent: {
whiteSpace: 'pre-wrap',
},
commentDate: {
marginRight: theme.spacing(3),
padding: theme.spacing(1, 0),
},
moreBox: {
textAlign: 'center',
marginTop: theme.spacing(2),
},
black: {
color: 'black',
},
ml1: {
marginLeft: theme.spacing(1),
},
pd1: {
padding: theme.spacing(1),
},
pdtb1: {
padding: theme.spacing(1, 0),
},
cancel: {
textDecoration: 'line-through',
},
}),
)
interface ICommentProps {
boardNo: number
postsNo: number
commentUseAt: boolean
deleteAt: number
// eslint-disable-next-line @typescript-eslint/ban-types
refreshCommentCount: (count) => void
}
interface ICommentSearchProps {
_page: number
_mode: 'replace' | 'append' | 'until'
}
const Comment: React.FC<ICommentProps> = ({
boardNo,
postsNo,
commentUseAt,
deleteAt,
refreshCommentCount,
}: ICommentProps) => {
const classes = useStyles()
const { user } = useUser()
const { t } = useTranslation()
const theme = useTheme()
const pagingSize = 2
// 현 페이지내 필요한 hook
const [page, setPage] = useState<number>(undefined)
const [totalPages, setTotalPages] = useState<number>(0)
// 버튼 component 상태 전이
const setSuccessSnackBar = useSetRecoilState(detailButtonsSnackAtom)
// 상태관리 hook
const setErrorState = useSetRecoilState(errorStateSelector)
const [openConfirm, setOpenConfirm] = useState<boolean>(false)
const [confirm, setConfirm] = useState<ConfirmDialogProps>({
open: openConfirm,
handleConfirm: () => {
setOpenConfirm(false)
},
handleClose: () => {
setOpenConfirm(false)
},
})
// const [comments, setComments] = useRecoilState(commentState)
const [comments, setComments] = useState<any[]>([])
// 댓글 데이터 복사본 리턴
const cloneComments = useCallback(
() => comments.slice(0, comments.length),
[comments],
)
// 페이지 조회
const getComments = useCallback(
({ _page, _mode }: ICommentSearchProps) => {
let searchPage = _page
let searchSize = pagingSize
if (_mode === 'until') {
searchSize = pagingSize * (_page + 1)
searchPage = 0
}
commentService
.list(boardNo, postsNo, searchSize, searchPage)
.then(result => {
setPage(_page)
// setTotalPages(result.totalPages)
setTotalPages(Math.ceil(result.groupElements / pagingSize))
refreshCommentCount(result.totalElements)
let arr = _mode === 'append' ? cloneComments() : []
arr.push(...result.content)
setComments(arr)
})
},
[boardNo, cloneComments, postsNo, refreshCommentCount],
)
// 전체 조회
const getAllComments = () => {
commentService.all(boardNo, postsNo).then(result => {
setPage(result.number)
setTotalPages(result.totalPages)
refreshCommentCount(result.totalElements)
let arr = []
arr.push(...result.content)
setComments(arr)
})
}
useEffect(() => {
if (page === undefined) {
getComments({ _page: 0, _mode: 'replace' })
}
}, [getComments, page])
// 댓글 갱신
const handleRefresh = useCallback(() => {
// getComments({ _page: 0, _mode: 'replace' }) // 첫페이지 재조회
getComments({ _page: page, _mode: 'until' }) // 현재 페이지까지 재조회
}, [getComments, page])
// 댓글 상태 초기화
const initComments = useCallback(() => {
let arr: IComment[] = cloneComments()
while (true) {
const index = arr.findIndex(a => a.mode === 'reply' || a.mode === 'edit')
if (index === -1) break
if (arr[index].mode === 'reply') {
arr.splice(index, 1)
} else {
arr[index].mode = 'none'
}
}
return arr
}, [cloneComments])
// 성공 callback
const successCallback = useCallback(() => {
setSuccessSnackBar('success')
// handleRefresh()
let arr: IComment[] = initComments()
setComments(arr)
}, [initComments, setSuccessSnackBar])
// 에러 callback
const errorCallback = useCallback(
(error: AxiosError) => {
setErrorState({
error,
})
},
[setErrorState],
)
// 댓글 더보기
const handleCommentMore = () => {
getComments({ _page: page + 1, _mode: 'append' })
}
// 댓글 답글쓰기
const handleCommentReply = async (parentCommentNo: number) => {
let arr: IComment[] = initComments()
const parentIndex = arr.findIndex(a => a.commentNo === parentCommentNo)
const reply: IComment = {
boardNo,
postsNo,
groupNo: arr[parentIndex].groupNo,
parentCommentNo,
depthSeq: arr[parentIndex].depthSeq + 1,
createdBy: user.userId,
createdName: user.userName,
commentContent: '',
mode: 'reply',
}
arr.splice(parentIndex + 1, 0, reply)
setComments(arr)
}
// 댓글 수정
const handleCommentEdit = async (commentNo: number) => {
let arr: IComment[] = initComments()
const index = arr.findIndex(a => a.commentNo === commentNo)
arr[index].mode = 'edit'
setComments(arr)
}
// 댓글 삭제
const handleCommentDelete = async (commentNo: number) => {
setConfirm({
open: openConfirm,
contentText: t('msg.confirm.delete'),
handleConfirm: () => {
setOpenConfirm(false)
commentService.delete({
boardNo,
postsNo,
commentNo,
callback: successCallback,
errorCallback,
})
},
handleClose: () => {
setOpenConfirm(false)
},
})
setOpenConfirm(true)
}
// handleSubmit 댓글 저장
const handleCommentSave = async (comment: IComment) => {
if (comment.commentNo > 0) {
await commentService.update({
callback: () => {
successCallback()
getComments({ _page: page, _mode: 'until' }) // 현재 페이지까지 재조회
},
errorCallback,
data: comment,
})
} else {
await commentService.save({
callback: () => {
successCallback()
if (comment.parentCommentNo) {
getComments({ _page: page, _mode: 'until' }) // 현재 페이지까지 재조회
} else {
getAllComments() // 마지막 페이지까지 조회
}
},
errorCallback,
data: comment,
})
}
}
// 취소
const handleCommentCancel = async () => {
let arr: IComment[] = initComments()
setComments(arr)
}
return (
<Box boxShadow={1} className={classes.commentRoot}>
<Box className={classes.commentTitle}>
<Typography variant="h4" component="h3" className={classes.pdtb1}>
{t('comment')}
</Typography>
<Link
href="#"
className={classNames({
[classes.black]: true,
[classes.ml1]: true,
[classes.pdtb1]: true,
})}
onClick={(event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault()
handleRefresh()
}}
>
<RefreshIcon fontSize="small" className={classes.commentIcon} />
</Link>
</Box>
{comments &&
comments.map(comment => {
if (comment.mode !== 'edit' && comment.mode !== 'reply') {
let buttons = []
if (commentUseAt && deleteAt === 0) {
buttons.push({
label: t('label.button.reply'),
size: 'small',
handleButton: () => {
handleCommentReply(comment.commentNo)
},
})
if (user?.userId === comment.createdBy) {
buttons.push({
label: t('label.button.edit'),
size: 'small',
handleButton: () => {
handleCommentEdit(comment.commentNo)
},
})
buttons.push({
label: t('label.button.delete'),
size: 'small',
handleButton: () => {
handleCommentDelete(comment.commentNo)
},
completeMessage: t('msg.success.delete'),
})
}
}
return (
<Card
key={`comment${comment.commentNo}`}
className={classNames({
[classes.commentBox]: true,
})}
style={{
paddingLeft: `${
theme.spacing(2) + comment.depthSeq * theme.spacing(4)
}px`,
}}
>
<CardContent className={classes.commentView}>
<Typography gutterBottom variant="h6" component="h4">
{comment.createdName}
</Typography>
{comment.deleteAt !== 0 && (
<>
<ErrorOutlineIcon
fontSize="small"
className={classes.commentIcon}
/>
{comment.deleteAt === 1 && t('common.delete.creator')}
{comment.deleteAt === 2 && t('common.delete.manager')}
</>
)}
<Typography variant="body2" color="textPrimary" component="p">
<Box
className={classNames({
[classes.commentContent]: true,
[classes.cancel]: comment.deleteAt !== 0,
})}
component="span"
>
{comment.commentContent}
</Box>
</Typography>
</CardContent>
<CardActions className={classes.commentView}>
<Typography
variant="body2"
color="textSecondary"
component="p"
className={classes.commentDate}
>
{comment.createdDate
? convertStringToDateFormat(
comment.createdDate,
'yyyy-MM-dd HH:mm:ss',
)
: ''}
</Typography>
{comment.deleteAt === 0 && (
<CustomButtons buttons={buttons} className="mg0" />
)}
</CardActions>
</Card>
)
}
return (
<Box
key={`comment${comment.commentNo}`}
boxShadow={1}
className={classes.pd1}
>
<CommentForm
boardNo={boardNo}
postsNo={postsNo}
commentNo={comment.commentNo}
commentContent={comment.commentContent}
groupNo={comment.groupNo}
parentCommentNo={comment.parentCommentNo}
depthSeq={comment.depthSeq}
handleCommentSave={handleCommentSave}
handleCommentCancel={handleCommentCancel}
/>
</Box>
)
})}
<Box className={classes.moreBox} hidden={page + 1 >= totalPages}>
<Button
startIcon={<ExpandMoreIcon />}
endIcon={<ExpandMoreIcon />}
onClick={handleCommentMore}
>
{t('common.more')}
</Button>
</Box>
{commentUseAt && deleteAt === 0 && (
<Box className={classes.pdtb1}>
<CommentForm
handleCommentSave={handleCommentSave}
boardNo={boardNo}
postsNo={postsNo}
/>
</Box>
)}
<ConfirmDialog
open={openConfirm}
contentText={confirm.contentText}
handleClose={confirm.handleClose}
handleConfirm={confirm.handleConfirm}
/>
</Box>
)
}
export { Comment }

View File

@@ -0,0 +1,14 @@
export const DEV = process.env.NODE_ENV !== 'production'
export const PORT = process.env.PORT || '3000'
export const PROXY_HOST = process.env.PROXY_HOST || `http://localhost:${PORT}`
export const TZ = process.env.TZ || 'Asia/Seoul'
export const SERVER_API_URL = process.env.SERVER_API_URL
export const CLAIM_NAME = process.env.CLAIM_NAME || 'Authorization'
export const AUTH_USER_ID = process.env.AUTH_USER_ID || 'token-id'
export const REFRESH_TOKEN = process.env.REFRESH_TOKEN || 'refresh-token'
export const ACCESS_TOKEN = process.env.ACCESS_TOKEN || 'access-token'
export const SITE_ID = process.env.SITE_ID

View File

@@ -0,0 +1,33 @@
import { PROXY_HOST } from './env'
export const DRAWER_WIDTH = 220
export const GRID_ROW_HEIGHT = 40
export const GRID_PAGE_SIZE = 10
export const GRID_ROWS_PER_PAGE_OPTION = [10, 20, 50, 100]
export const DEFAULT_ERROR_MESSAGE = 'Sorry.. Something Wrong...😱'
export const DEFAULT_APP_NAME = 'MSA Admin Template'
export const EDITOR_LOAD_IMAGE_URL = '/portal-service/api/v1/images/editor/'
// .htm, .html, .txt, .png/.jpg/etc, .pdf, .xlsx. .xls
export const DEFAULT_ACCEPT_FILE_EXT =
'text/html, text/plain, image/*, .pdf, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel'
export const BASE_URL = `${PROXY_HOST}/server`
export const ADMIN_LOGO_PATH = '/images/adminLogo.png'
export const ADMIN_LOGO_TEXT = 'MSA Admin'
export const CUSTOM_HEADER_SITE_ID_KEY = 'X-Site-Id'
export const ACCESS_LOG_TIMEOUT = 30 * 60 * 1000
export const ACCESS_LOG_ID = 'accessLogId'
export const PUBLIC_PAGES = ['/404', '/', '/reload', '/_error']

View File

@@ -0,0 +1,25 @@
import { useState } from 'react'
export const useLocalStorage = (key: string, initialValue: unknown = '') => {
const [storeValue, setStoreValue] = useState(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
return initialValue
}
})
const setValue = (value: unknown) => {
try {
const valueToStore = value instanceof Function ? value(storeValue) : value
setStoreValue(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
} catch (error) {
console.error(`useLocalStorage setValue error : ${error.message}`)
}
}
return [storeValue, setValue]
}

View File

@@ -0,0 +1,11 @@
import { useEffect, useState } from 'react'
export default function useMounted() {
const [mounted, setMounted] = useState<boolean>(false)
useEffect(() => {
setMounted(true)
}, [])
return mounted
}

View File

@@ -0,0 +1,17 @@
import { pageAtom, pageSelector } from '@stores'
import { useState } from 'react'
import { useRecoilValue, useSetRecoilState } from 'recoil'
export default function usePage(conditionKey: string, initPage: number = 0) {
const pageState = useRecoilValue(pageAtom(conditionKey))
const setValue = useSetRecoilState(pageSelector(conditionKey))
const [page, setPage] = useState<number>(pageState || initPage)
const setPageValue = (num: number) => {
setValue(num)
setPage(num)
}
return { page, setPageValue }
}

View File

@@ -0,0 +1,12 @@
import { IKeywordType } from '@components/Search'
import { useEffect, useState } from 'react'
export default function useSearchTypes(init: IKeywordType[]) {
const [searchTypes, setSearchTypes] = useState<IKeywordType[]>([])
useEffect(() => {
setSearchTypes(init)
}, [])
return searchTypes
}

View File

@@ -0,0 +1,36 @@
import useSWR from 'swr'
import axios from 'axios'
import { AUTH_USER_ID } from '@constants/env'
import { loginSerivce } from '@service'
export default function useUser() {
const { data, error, mutate } = useSWR(
`/user-service/api/v1/users`,
async (url: string) => {
let userId = axios.defaults.headers.common[AUTH_USER_ID]
if (!userId) {
await loginSerivce.silentRefresh()
}
userId = axios.defaults.headers.common[AUTH_USER_ID]
if (userId) {
return axios.get(`${url}/${userId}`).then(res => res.data)
} else {
throw new Error('No User')
}
},
)
const loading = !data && !error
const isLogin = !Boolean(error) && Boolean(data)
const loggedOut =
error && (error.response?.status === 401 || error.response?.status === 403)
return {
user: data,
loading: loading,
isLogin,
error,
mutate,
loggedOut,
}
}

View File

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

View File

@@ -0,0 +1,92 @@
// module with classes and logic for working with local storage in browsers via JavaScript
// see also: http://professorweb.ru/my/html/html5/level5/5_1.php
export interface IStorageItem {
key: string
value: any
}
export class StorageItem {
key: string
value: any
constructor(data: IStorageItem) {
this.key = data.key
this.value = data.value
}
}
// class for working with local storage in browser (common that can use other classes for store some data)
export class LocalStorageWorker {
localStorageSupported: boolean
constructor() {
this.localStorageSupported =
typeof window['localStorage'] != 'undefined' &&
window['localStorage'] != null
}
// add value to storage
add(key: string, item: string) {
if (this.localStorageSupported) {
localStorage.setItem(key, item)
}
}
// get all values from storage (all items)
getAllItems(): Array<StorageItem> {
var list = new Array<StorageItem>()
for (var i = 0; i < localStorage.length; i++) {
var key = localStorage.key(i)
var value = localStorage.getItem(key)
list.push(
new StorageItem({
key: key,
value: value,
}),
)
}
return list
}
// get only all values from localStorage
getAllValues(): Array<any> {
var list = new Array<any>()
for (var i = 0; i < localStorage.length; i++) {
var key = localStorage.key(i)
var value = localStorage.getItem(key)
list.push(value)
}
return list
}
// get one item by key from storage
get(key: string): string {
if (this.localStorageSupported) {
var item = localStorage.getItem(key)
return item
} else {
return null
}
}
// remove value from storage
remove(key: string) {
if (this.localStorageSupported) {
localStorage.removeItem(key)
}
}
// clear storage (remove all items from it)
clear() {
if (this.localStorageSupported) {
localStorage.clear()
}
}
}

View File

@@ -0,0 +1,40 @@
import { TZ } from '@constants/env'
import { format as fnsFormat, Locale } from 'date-fns'
import { utcToZonedTime } from 'date-fns-tz'
import { ko, enUS } from 'date-fns/locale'
type DateType = number | Date
export const defaultlocales: Record<string, Locale> = { ko, enUS }
const locale =
typeof window !== 'undefined'
? defaultlocales[window.__localeId__]
: defaultlocales[global.__localeId__] // Check browser, server
// by providing a default string of 'PP' or any of its variants for `formatStr`
// it will format dates in whichever way is appropriate to the locale
export const format = (date: DateType, formatStr = 'PP') => {
return fnsFormat(date, formatStr, {
locale,
})
}
export const getCurrentDate = (timezone?: string) => {
return utcToZonedTime(Date.now(), timezone || TZ)
}
export const convertStringToDate = (
date: string | Date,
timezone: string = TZ,
) => {
return utcToZonedTime(new Date(date), timezone)
}
export const convertStringToDateFormat = (
date: string | Date,
formatStr = 'yyyy-MM-dd',
timezone: string = TZ,
) => {
return format(convertStringToDate(date, timezone), formatStr)
}

View File

@@ -0,0 +1,28 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import translationEn from 'public/locales/en/common.json'
import translationKo from 'public/locales/ko/common.json'
import { DEV } from '@constants/env'
const resources = {
en: {
translation: translationEn,
},
ko: {
translation: translationKo,
},
}
i18n.use(initReactI18next).init({
resources,
lng: 'ko',
fallbackLng: 'ko',
debug: DEV,
keySeparator: false,
interpolation: {
escapeValue: false,
},
})
export default i18n

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

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