frontend add

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

View File

@@ -0,0 +1,39 @@
import { useRouter } from 'next/router'
import React from 'react'
interface ActiveLinkProps
extends React.DetailedHTMLProps<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
> {
children: React.ReactNode
handleActiveLinkClick?: () => void
}
const ActiveLink = (props: ActiveLinkProps) => {
const { children, handleActiveLinkClick, href, ...rest } = props
const router = useRouter()
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault()
if (handleActiveLinkClick) {
handleActiveLinkClick()
return
}
if (href === 'prev') {
router.back()
return
}
router.push(href)
}
return (
<a href={href} onClick={handleClick} {...rest}>
{children}
</a>
)
}
export default ActiveLink

View File

@@ -0,0 +1,21 @@
import { ASSET_PATH } from '@constants/env'
import React from 'react'
export interface IGlobalStyleProps {
children: React.ReactNode
}
const GlobalStyles = ({ children }: IGlobalStyleProps) => {
return (
<div>
{children}
<style jsx global>
{`
@import '${ASSET_PATH}/layout.css';
`}
</style>
</div>
)
}
export default GlobalStyles

View File

@@ -0,0 +1,163 @@
import Layout from '@components/Layout'
import Loader from '@components/Loader'
import Wrapper from '@components/Wrapper'
import {
ACCESS_LOG_ID,
ACCESS_LOG_TIMEOUT,
DEFAULT_ERROR_MESSAGE,
PUBLIC_PAGES,
} from '@constants'
import useMounted from '@hooks/useMounted'
import useUser from '@hooks/useUser'
import { menuService, statisticsService } from '@service'
import {
currentMenuStateAtom,
flatMenusSelect,
ISideMenu,
menuStateAtom,
userAtom,
} from '@stores'
import { NextComponentType, NextPageContext } from 'next'
import { useRouter } from 'next/router'
import { useSnackbar } from 'notistack'
import React, { useCallback, useEffect } from 'react'
import { useCookies } from 'react-cookie'
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
import { SWRConfig } from 'swr'
import { v4 as uuidv4 } from 'uuid'
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 authPage = pathname?.startsWith('/auth/')
const errorPage = router.pathname === '/404' || router.pathname === '/_error'
const { enqueueSnackbar } = useSnackbar()
const { loading } = useUser()
const user = useRecoilValue(userAtom)
const setMenus = useSetRecoilState(menuStateAtom)
const [currentMenu, setCurrentMenus] = useRecoilState(currentMenuStateAtom)
const flatMenus = useRecoilValue(flatMenusSelect)
const mounted = useMounted()
const { data, mutate } = menuService.getMenus()
const [cookies, setCookie] = useCookies([ACCESS_LOG_ID])
// access log
useEffect(() => {
if (!errorPage) {
const date = new Date()
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 (!loading) {
mutate()
}
}, [user])
useEffect(() => {
if (data) {
setMenus(data)
}
}, [data])
//current menu
const findCurrent = useCallback(
(path: string) => {
return flatMenus.find(item => item.urlPath === path)
},
[flatMenus, pathname],
)
useEffect(() => {
if (mounted && flatMenus) {
let path =
router.asPath.indexOf('?') === -1
? router.asPath
: router.asPath.substring(0, router.asPath.indexOf('?'))
let current: ISideMenu | undefined = undefined
while (true) {
current = findCurrent(path)
path = path.substring(0, path.lastIndexOf('/'))
if (current || path.length < 1) {
break
}
}
// 권한 없는 페이지 대해 호출이 있으면 404로 redirect
if (!authPage && flatMenus.length > 0 && !current) {
if (!PUBLIC_PAGES.includes(router.asPath)) {
router.push('/404')
}
}
setCurrentMenus(current)
}
}, [router, mounted, flatMenus])
if (loading) {
return <Loader />
}
if (!authPage && !(currentMenu || PUBLIC_PAGES.includes(router.asPath))) {
return null
}
return errorPage ? (
<Wrapper>
<Component {...pageProps} />
</Wrapper>
) : (
<Layout main={pathname === '/'} isLeft={!!currentMenu}>
<SWRConfig
value={{
onError: (error, key) => {
if (key !== '/user-service/api/v1/users') {
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 {...pageProps} />
</Wrapper>
</SWRConfig>
</Layout>
)
}
export default App

View File

@@ -0,0 +1,69 @@
import IconButton from '@material-ui/core/IconButton'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import ClearIcon from '@material-ui/icons/Clear'
import { fileService, IAttachmentResponse } from '@service'
import { formatBytes } from '@utils'
import produce from 'immer'
import React from 'react'
import { useTranslation } from 'react-i18next'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
icon: {
padding: '0px 12px',
},
}),
)
interface AttachListProps {
data: IAttachmentResponse[]
setData: React.Dispatch<React.SetStateAction<IAttachmentResponse[]>>
readonly?: true
}
const AttachList = (props: AttachListProps) => {
const { data, setData, readonly } = props
const classes = useStyles()
const { t } = useTranslation()
const handleDelete = (item: IAttachmentResponse) => {
setData(
produce(data, draft => {
const idx = draft.findIndex(attachment => attachment.id === item.id)
draft[idx].isDelete = true
}),
)
}
return (
<>
{data &&
data.map(file => {
return file.isDelete ? null : (
<div key={`file-div-${file.id}`} id="attach-div">
<a
id="attach-list"
key={`file-${file.id}`}
href={`${fileService.downloadUrl}/${file.id}`}
download={file.originalFileName}
>
{`${file.originalFileName} (${formatBytes(file.size)})`}
</a>
{!readonly && (
<IconButton
className={classes.icon}
key={`file-clear-${file.id}`}
onClick={(e: React.MouseEvent) => {
handleDelete(file)
}}
>
<ClearIcon fontSize="inherit" />
</IconButton>
)}
</div>
)
})}
</>
)
}
export default AttachList

View File

@@ -0,0 +1,127 @@
import ValidationAlert from '@components/ValidationAlert'
import { EmailStorage } from '@libs/Storage/emailStorage'
import Alert from '@material-ui/lab/Alert'
import React, { useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { makeStyles, Theme } from '@material-ui/core/styles'
const useStyles = makeStyles((theme: Theme) => ({
alert: {
marginTop: theme.spacing(2),
whiteSpace: 'break-spaces',
wordBreak: 'keep-all',
},
}))
export type loginFormType = {
email?: string
password?: string
isRemember?: boolean
}
interface LoginFormProps {
errorMessage?: string
handleLogin: ({ email, password }: loginFormType) => void
}
const LoginForm = (props: LoginFormProps) => {
const { errorMessage, handleLogin } = props
const classes = useStyles()
const { t } = useTranslation()
const emails = new EmailStorage('login')
const [checked, setChecked] = useState<boolean>(emails.get().isRemember)
const {
register,
handleSubmit,
formState: { errors },
getValues,
} = useForm<loginFormType>({
defaultValues: {
email: emails.get().email,
},
})
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 (
<fieldset>
<form noValidate onSubmit={handleSubmit(onSubmit)}>
<input
type="text"
placeholder={t('user.email')}
{...register('email', {
required: `${t('user.email')} ${t('valid.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: `${t('user.email')} ${t('valid.format_not_match')}`,
},
})}
/>
{errors.email && (
<ValidationAlert
className={classes.alert}
fieldError={errors.email}
label={t('user.email')}
/>
)}
<input
type="password"
placeholder={t('user.password')}
{...register('password', {
required: `${t('user.password')} ${t('valid.required')}`,
})}
/>
{errors.password && (
<ValidationAlert
className={classes.alert}
fieldError={errors.password}
label={t('user.password')}
/>
)}
<div className="save">
<input
type="checkbox"
id="save"
onChange={handleChange}
checked={checked}
/>
<label htmlFor="save">{t('login.email_save')}</label>
</div>
{errorMessage && (
<Alert className={classes.alert} severity="warning">
{errorMessage}
</Alert>
)}
<button>{t('common.login')}</button>
</form>
</fieldset>
)
}
export { LoginForm }

View File

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

View File

@@ -0,0 +1,126 @@
import CollapsibleTable from '@components/TableList/CollapsibleTable'
import { convertStringToDateFormat, format as dateFormat } from '@libs/date'
import {
GridCellParams,
GridRowData,
GridValueFormatterParams,
} from '@material-ui/data-grid'
import { Page } from '@service'
import { useTranslation } from 'next-i18next'
import React, { useCallback, useMemo } from 'react'
import { ColumnsType } from '.'
interface FAQBoardListProps {
data: Page
pageSize: number
page: number
handleChangePage: (
event: React.MouseEvent<HTMLButtonElement> | null,
page: number,
) => void
}
const getColumns: ColumnsType = (data, t) => {
return [
{
field: 'postsTitle',
headerName: t('posts.posts_title'),
headerAlign: 'center',
minWidth: 400,
sortable: false,
cellClassName: 'title',
},
{
field: 'createdDate',
headerName: t('common.created_date'),
headerAlign: 'center',
align: 'center',
minWidth: 140,
cellClassName: 'span',
sortable: false,
valueFormatter: (params: GridValueFormatterParams) =>
params.value
? dateFormat(new Date(params.value as string), 'yyyy-MM-dd')
: null,
},
{
field: 'readCount',
headerName: t('common.read_count'),
headerAlign: 'center',
align: 'center',
cellClassName: 'count',
minWidth: 100,
sortable: false,
},
]
}
const getXsColumns: ColumnsType = (data, t) => {
return [
{
field: 'postsTitle',
headerName: t('posts.posts_title'),
headerAlign: 'center',
sortable: false,
renderCell,
},
]
function renderCell(params: GridCellParams) {
return (
<div>
<div className="title">{params.value}</div>
<div className="sub">
<p>
{convertStringToDateFormat(params.row.createdDate, 'yyyy-MM-dd')}
</p>
<p>{params.row.readCount}</p>
</div>
</div>
)
}
}
const FAQBaordList = ({
data,
pageSize,
page,
handleChangePage,
}: FAQBoardListProps) => {
const { t } = useTranslation()
const columns = useMemo(() => getColumns(data, t), [data, t])
const xsColumns = useMemo(() => getXsColumns(data, t), [data, t])
const renderCollapseRow = useCallback((row: GridRowData) => {
return (
<>
<p dangerouslySetInnerHTML={{ __html: row.postsContent }} />
<p
className="answer"
dangerouslySetInnerHTML={{ __html: row.postsAnswerContent }}
/>
</>
)
}, [])
return (
<div className="list">
<CollapsibleTable
columns={columns}
xsColumns={xsColumns}
hideColumns
rowId="postsNo"
rows={data?.content || []}
renderCollapseRow={renderCollapseRow}
page={page}
first={data?.first}
last={data?.last}
totalPages={data?.totalPages}
handleChangePage={handleChangePage}
/>
</div>
)
}
export { FAQBaordList }

View File

@@ -0,0 +1,229 @@
import { SelectBox, SelectType } from '@components/Inputs'
import Search from '@components/Search'
import DataGridTable from '@components/TableList/DataGridTable'
import { GRID_ROWS_PER_PAGE_OPTION } from '@constants'
import useSearchTypes from '@hooks/useSearchTypes'
import { convertStringToDateFormat, format as dateFormat } from '@libs/date'
import { Box } from '@material-ui/core'
import {
GridCellParams,
GridValueFormatterParams,
GridValueGetterParams,
MuiEvent,
} from '@material-ui/data-grid'
import FiberNewIcon from '@material-ui/icons/FiberNew'
import { Page } from '@service'
import { conditionAtom } from '@stores'
import { rownum } from '@utils'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { createRef, useMemo } from 'react'
import { useRecoilValue } from 'recoil'
import { ColumnsType } from '.'
const getColumns: ColumnsType = (data, t) => {
return [
{
field: 'rownum',
headerName: t('common.no'),
headerAlign: 'center',
align: 'center',
sortable: false,
valueGetter: (params: GridValueGetterParams) =>
rownum(data, params.api.getRowIndex(params.id), 'desc'),
},
{
field: 'postsTitle',
headerName: t('posts.posts_title'),
headerAlign: 'center',
flex: 1,
sortable: false,
cellClassName: 'title',
renderCell: function renderCellPostsTitle(params: GridValueGetterParams) {
// eslint-disable-next-line no-param-reassign
// gridApiRef.current = params.api // api
return (
<>
{params.row.noticeAt ? `[${t('common.notice')}] ` : ''}
{params.row.postsTitle}
{params.row.commentCount && params.row.commentCount !== 0 ? (
<Box
color="red"
component="span"
>{` [${params.row.commentCount}]`}</Box>
) : (
''
)}
{params.row.isNew && <FiberNewIcon color="secondary" />}
</>
)
},
},
{
field: 'createdName',
headerName: t('common.created_by'),
headerAlign: 'center',
align: 'center',
minWidth: 110,
sortable: false,
},
{
field: 'createdDate',
headerName: t('common.created_date'),
headerAlign: 'center',
align: 'center',
minWidth: 140,
sortable: false,
valueFormatter: (params: GridValueFormatterParams) =>
params.value
? dateFormat(new Date(params.value as string), 'yyyy-MM-dd')
: null,
},
{
field: 'readCount',
headerName: t('common.read_count'),
headerAlign: 'center',
align: 'center',
minWidth: 100,
sortable: false,
},
]
}
const getXsColumns: ColumnsType = (data, t) => {
return [
{
field: 'postsTitle',
headerName: t('posts.posts_title'),
headerAlign: 'center',
sortable: false,
renderCell,
},
]
function renderCell(params: GridCellParams) {
return (
<div>
<div className="title">{params.value}</div>
<div className="sub">
<p>{params.row.createdName}</p>
<p>
{convertStringToDateFormat(params.row.createdDate, 'yyyy-MM-dd')}
</p>
<p>{params.row.readCount}</p>
</div>
</div>
)
}
}
interface NormalBoardListProps {
data: Page
conditionKey: string
pageSize: number
handlePageSize: (size: number) => void
page: number
handlePageChange: (page: number, details?: any) => void
handleSearch: () => void
}
const NormalBoardList = (props: NormalBoardListProps) => {
const {
data,
conditionKey,
handleSearch,
pageSize,
page,
handlePageSize,
handlePageChange,
} = props
const { t } = useTranslation()
const router = useRouter()
// 조회조건 상태관리
const keywordState = useRecoilValue(conditionAtom(conditionKey))
const pageSizeRef = createRef<SelectType>()
// 조회조건 select items
const searchTypes = useSearchTypes([
{
value: 'postsTitle',
label: t('posts.posts_title'),
},
{
value: 'postsContent',
label: t('posts.posts_content'),
},
])
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
handlePageSize(parseInt(e.target.value, 10))
}
const handleCellClick = (
params: GridCellParams,
event: MuiEvent<React.MouseEvent>,
) => {
if (params.field !== 'postsTitle') {
return
}
router.push(
`${router.asPath}/view/${
params.id
}?size=${pageSize}&page=${page}&keywordType=${
typeof keywordState?.keywordType === 'undefined'
? ''
: keywordState?.keywordType
}&keyword=${
typeof keywordState?.keyword === 'undefined'
? ''
: keywordState?.keyword
}`,
)
}
// 목록컬럼 재정의 > 컬럼에 비지니스 로직이 필요한 경우
const columns = useMemo(() => getColumns(data, t), [data, t])
const xsColumns = useMemo(() => getXsColumns(data, t), [data, t])
const rowsPerPageSizeOptinos = GRID_ROWS_PER_PAGE_OPTION.map(item => {
return {
value: item,
label: `${item}`,
}
})
return (
<>
<fieldset>
<div>
<SelectBox
ref={pageSizeRef}
options={rowsPerPageSizeOptinos}
customHandleChange={handlePageSizeChange}
/>
</div>
<div>
<Search
options={searchTypes}
conditionKey={conditionKey}
handleSearch={handleSearch}
/>
</div>
</fieldset>
<DataGridTable
columns={columns}
rows={data?.content}
xsColumns={xsColumns}
getRowId={r => r.postsNo}
pageSize={pageSize}
rowCount={data?.totalElements}
page={page}
onPageChange={handlePageChange}
paginationMode="server"
onCellClick={handleCellClick}
/>
</>
)
}
export { NormalBoardList }

View File

@@ -0,0 +1,145 @@
import CollapsibleTable from '@components/TableList/CollapsibleTable'
import { convertStringToDateFormat, format as dateFormat } from '@libs/date'
import {
GridCellParams,
GridRowData,
GridValueFormatterParams,
} from '@material-ui/data-grid'
import { Page } from '@service'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import React, { useCallback, useMemo } from 'react'
import { ColumnsType } from '.'
interface QnABaordListProps {
data: Page
pageSize: number
page: number
handleChangePage: (
event: React.MouseEvent<HTMLButtonElement> | null,
page: number,
) => void
}
const getColumns: ColumnsType = (data, t) => {
return [
{
field: 'postsTitle',
headerName: t('posts.posts_title'),
headerAlign: 'center',
width: 500,
sortable: false,
cellClassName: 'title',
},
{
field: 'postsState',
headerName: t('posts.posts_title'),
headerAlign: 'center',
width: 100,
sortable: false,
cellClassName: 'span',
renderCell: (params: GridCellParams) => {
/**
* @todo
* 상태 컬럼 생기면 수정 필요
*/
if (params.value === 'ing') {
return <span className="answering">{params.value}</span>
} else {
return <span>{params.value}test</span>
}
},
},
{
field: 'createdDate',
headerName: t('common.created_date'),
headerAlign: 'center',
align: 'center',
width: 140,
cellClassName: 'span',
sortable: false,
valueFormatter: (params: GridValueFormatterParams) =>
params.value
? dateFormat(new Date(params.value as string), 'yyyy-MM-dd')
: null,
},
{
field: 'readCount',
headerName: t('common.read_count'),
headerAlign: 'center',
align: 'center',
cellClassName: 'count',
width: 100,
sortable: false,
},
]
}
const getXsColumns: ColumnsType = (data, t) => {
return [
{
field: 'postsTitle',
headerName: t('posts.posts_title'),
headerAlign: 'center',
sortable: false,
renderCell: (params: GridCellParams) => {
return (
<div>
<div className="title">{params.value}</div>
<div className="sub">
<p>{params.row.postsState}</p>
<p>
{convertStringToDateFormat(
params.row.createdDate,
'yyyy-MM-dd',
)}
</p>
<p>{params.row.readCount}</p>
</div>
</div>
)
},
},
]
}
const QnABaordList = ({ data, page, handleChangePage }: QnABaordListProps) => {
const router = useRouter()
const { t, i18n } = useTranslation()
const columns = useMemo(() => getColumns(data, t), [data, router.query, i18n])
const xsColumns = useMemo(
() => getXsColumns(data, t),
[data, router.query, i18n],
)
const renderCollapseRow = useCallback(
(row: GridRowData) => {
return (
<>
<p>{row['postContent']}</p>
<p className="answer">{row['postAnswerContent']}</p>
</>
)
},
[data],
)
return (
<div className="list">
<CollapsibleTable
columns={columns}
xsColumns={xsColumns}
hideColumns={true}
rows={data?.content || []}
renderCollapseRow={renderCollapseRow}
page={page}
first={data?.first}
last={data?.last}
totalPages={data?.totalPages}
handleChangePage={handleChangePage}
/>
</div>
)
}
export { QnABaordList }

View File

@@ -0,0 +1,13 @@
import { CollapseColDef } from '@components/TableList'
import { GridColDef } from '@material-ui/data-grid'
import { Page } from '@service'
import { TFunction } from 'next-i18next'
export * from './NormalBoardList'
export * from './FAQBoardList'
export * from './QnABoardList'
export type ColumnsType = (
data: Page,
t?: TFunction,
) => GridColDef[] | CollapseColDef[]

View File

@@ -0,0 +1,34 @@
import ActiveLink from '@components/ActiveLink'
import React from 'react'
export interface IButtons {
id: ValueType
title: string
href: string
className?: string
handleClick?: () => void
}
interface BottomButtonsProps {
handleButtons: IButtons[]
}
const BottomButtons = (props: BottomButtonsProps) => {
const { handleButtons } = props
return (
<div className="btn_center">
{handleButtons &&
handleButtons.map(item => (
<ActiveLink
href={item.href}
children={item.title}
key={`bottom-button-${item.id}`}
className={item.className || ''}
handleActiveLinkClick={item.handleClick}
/>
))}
</div>
)
}
export { BottomButtons }

View File

@@ -0,0 +1,78 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import GoogleLogin from 'react-google-login'
import { GOOGLE_CLIENT_ID } from '@constants/env'
import CustomConfirm, { CustomConfirmPrpps } from '@components/CustomConfirm'
export interface ISocialButton {
handleClick?: (response: any) => void
confirmMessage?: string
}
const GoogleLoginButton = (props: ISocialButton) => {
const { handleClick, confirmMessage } = props
const { t } = useTranslation()
const [customConfirm, setCustomConfirm] = useState<CustomConfirmPrpps>({
open: false,
handleConfirm: () => {},
handleCancel: () => {},
})
return (
<>
<GoogleLogin
clientId={GOOGLE_CLIENT_ID}
render={(_props: any) => (
<a
href="#"
className="social google"
onClick={event => {
event.preventDefault()
if (confirmMessage) {
setCustomConfirm({
open: true,
contentText: confirmMessage,
handleConfirm: () => {
setCustomConfirm({
open: false,
handleConfirm: () => {},
handleCancel: () => {},
})
_props.onClick(event)
},
handleCancel: () => {
setCustomConfirm({
open: false,
handleConfirm: () => {},
handleCancel: () => {},
})
},
} as CustomConfirmPrpps)
} else {
_props.onClick(event)
}
}}
>
{t('label.text.google')}
</a>
)}
onSuccess={handleClick}
onFailure={handleClick}
cookiePolicy="single_host_origin"
/>
{customConfirm && (
<CustomConfirm
handleConfirm={customConfirm.handleConfirm}
handleCancel={customConfirm.handleCancel}
contentText={customConfirm.contentText}
open={customConfirm.open}
/>
)}
</>
)
}
export { GoogleLoginButton }

View File

@@ -0,0 +1,73 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import KakaoLogin from 'react-kakao-login'
import { KAKAO_JAVASCRIPT_KEY } from '@constants/env'
import { ISocialButton } from '@components/Buttons/GoogleLoginButton'
import CustomConfirm, { CustomConfirmPrpps } from '@components/CustomConfirm'
const KakaoLoginButton = (props: ISocialButton) => {
const { handleClick, confirmMessage } = props
const { t } = useTranslation()
const [customConfirm, setCustomConfirm] = useState<CustomConfirmPrpps>({
open: false,
handleConfirm: () => {},
handleCancel: () => {},
})
return (
<>
<KakaoLogin
token={KAKAO_JAVASCRIPT_KEY}
onSuccess={handleClick}
onFail={handleClick}
render={(_props: any) => (
<a
href="#"
className="social kakao"
onClick={event => {
event.preventDefault()
if (confirmMessage) {
setCustomConfirm({
open: true,
contentText: confirmMessage,
handleConfirm: () => {
setCustomConfirm({
open: false,
handleConfirm: () => {},
handleCancel: () => {},
})
_props.onClick(event)
},
handleCancel: () => {
setCustomConfirm({
open: false,
handleConfirm: () => {},
handleCancel: () => {},
})
},
} as CustomConfirmPrpps)
} else {
_props.onClick(event)
}
}}
>
{t('label.text.kakao')}
</a>
)}
/>
{customConfirm && (
<CustomConfirm
handleConfirm={customConfirm.handleConfirm}
handleCancel={customConfirm.handleCancel}
contentText={customConfirm.contentText}
open={customConfirm.open}
/>
)}
</>
)
}
export { KakaoLoginButton }

View File

@@ -0,0 +1,182 @@
import { ISocialButton } from '@components/Buttons/GoogleLoginButton'
import CustomConfirm, { CustomConfirmPrpps } from '@components/CustomConfirm'
import { NAVER_CALLBACK_URL, NAVER_CLIENT_ID } from '@constants/env'
import useMounted from '@hooks/useMounted'
import { useTranslation } from 'next-i18next'
import { useCallback, useEffect, useState } from 'react'
import { ExtendedWindow } from 'react-kakao-login/lib/types'
// declare global {
// interface window {
// naver: any
// }
// }
declare let window: ExtendedWindow
const NaverLoginButton = (loginButtonProps: ISocialButton) => {
const { handleClick, confirmMessage } = loginButtonProps
const { t } = useTranslation()
const mounted = useMounted()
const [customConfirm, setCustomConfirm] = useState<CustomConfirmPrpps>({
open: false,
handleConfirm: () => {},
handleCancel: () => {},
})
const NAVER_ID_SDK_URL =
'https://static.nid.naver.com/js/naveridlogin_js_sdk_2.0.0.js'
/**
* 이 함수는 브라우저 환경에서만 호출이 되야 한다. window 객체에 직접 접근한다.
* @param props
*/
const initLoginButton = () => {
const clientId = NAVER_CLIENT_ID
const callbackUrl = NAVER_CALLBACK_URL
const onSuccess = handleClick
const onFailure = handleClick
const naver = window['naver']
const naverLogin = new naver.LoginWithNaverId({
callbackUrl,
clientId,
isPopup: true,
loginButton: { color: 'green', type: 3, height: 60 },
})
naverLogin.init()
if (!window.opener) {
naver.successCallback = data => {
return onSuccess(data)
}
naver.failureCallback = onFailure
} else {
naverLogin.getLoginStatus(status => {
if (status) {
window.opener.naver
.successCallback({
...naverLogin.accessToken,
user: naverLogin.user,
})
.then(() => {
window.close()
})
.catch(() => {
window.close()
})
} else {
window.opener.naver
.failureCallback()
.then(() => {
window.close()
})
.catch(() => {
window.close()
})
}
})
}
}
const appendNaverButton = () => {
if (document && document.querySelectorAll('#naverIdLogin').length === 0) {
let naverId = document.createElement('div')
naverId.id = 'naverIdLogin'
naverId.style.position = 'absolute'
naverId.style.top = '-10000px'
document.body.appendChild(naverId)
}
}
const loadScript = useCallback(() => {
if (mounted) {
if (
document &&
document.querySelectorAll('#naver-login-sdk').length === 0
) {
let script = document.createElement('script')
script.id = 'naver-login-sdk'
script.src = NAVER_ID_SDK_URL
script.onload = () => {
return initLoginButton()
}
document.head.appendChild(script)
} else {
initLoginButton()
}
}
}, [mounted])
useEffect(() => {
appendNaverButton()
loadScript()
}, [])
const handleLogin = () => {
if (!document || !document.querySelector('#naverIdLogin').firstChild) {
return
}
const naverLoginButton = document.querySelector('#naverIdLogin').firstChild
// @ts-ignore
naverLoginButton.href = 'javascript:void(0);'
// naverLoginButton.click()
const e = new MouseEvent('click', {
bubbles: false,
cancelable: true,
view: window,
})
naverLoginButton.dispatchEvent(e)
}
return (
<>
<a
href="#"
className="social naver"
onClick={event => {
event.preventDefault()
if (confirmMessage) {
setCustomConfirm({
open: true,
contentText: confirmMessage,
handleConfirm: () => {
setCustomConfirm({
open: false,
handleConfirm: () => {},
handleCancel: () => {},
})
handleLogin()
},
handleCancel: () => {
setCustomConfirm({
open: false,
handleConfirm: () => {},
handleCancel: () => {},
})
},
} as CustomConfirmPrpps)
} else {
handleLogin()
}
}}
>
{t('label.text.naver')}
</a>
<CustomConfirm
handleConfirm={customConfirm?.handleConfirm}
handleCancel={customConfirm?.handleCancel}
contentText={customConfirm?.contentText}
open={customConfirm?.open}
/>
</>
)
}
export { NaverLoginButton }

View File

@@ -0,0 +1,5 @@
export * from './BottomButtons'
export * from './KakaoLoginButton'
export * from './NaverLoginButton'
// export * from './NaverLoginButton2'
export * from './GoogleLoginButton'

View File

@@ -0,0 +1,62 @@
import React, { createRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRecoilValue } from 'recoil'
import { userAtom } from '@stores'
import { CommentSavePayload } from '@service'
import { EditComments, EditCommentsType } from './EditComments'
interface WriteByButtonProps {
handleRegist: (comment: CommentSavePayload) => void
parentComment: CommentSavePayload
}
const AddComments = (props: WriteByButtonProps) => {
const { handleRegist, parentComment } = props
const { t } = useTranslation()
const user = useRecoilValue(userAtom)
const commentsRef = createRef<EditCommentsType>()
const [editState, setEditState] = useState<boolean>(false)
const handleReply = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
setEditState(!editState)
}
const handleCancel = () => {
commentsRef.current?.clear()
setEditState(false)
}
return (
<>
<a href="#" onClick={handleReply}>
{user && t('label.button.reply')}
</a>
{Boolean(editState) === true && (
<div>
{user && (
<EditComments
ref={commentsRef}
handleRegist={handleRegist}
handleCancel={handleCancel}
comment={
{
boardNo: parentComment.boardNo,
postsNo: parentComment.postsNo,
groupNo: parentComment.groupNo,
parentCommentNo: parentComment.commentNo,
depthSeq: parentComment.depthSeq + 1,
} as CommentSavePayload
}
/>
)}
</div>
)}
</>
)
}
export { AddComments }

View File

@@ -0,0 +1,45 @@
import React from 'react'
import { CommentSavePayload } from '@service'
import { ViewComments } from './ViewComments'
import { AddComments } from './AddComments'
interface CommentsListProps {
handleRegist: (comment: CommentSavePayload) => void
handleDelete: (comment: CommentSavePayload) => void
comments: CommentSavePayload[]
}
const CommentsList = (props: CommentsListProps) => {
const { handleRegist, handleDelete, comments } = props
return (
<>
{comments.length > 0 ? (
<ul>
{comments.map(item => (
<li key={`comments-li-${item.commentNo}`}>
<div
style={{
paddingLeft: `${item.depthSeq * 30}px`,
}}
>
<div className="writtenComment">
<ViewComments handleDelete={handleDelete} comment={item} />
</div>
<div className="reply">
<AddComments
handleRegist={handleRegist}
parentComment={item}
/>
</div>
</div>
</li>
))}
</ul>
) : null}
</>
)
}
export { CommentsList }

View File

@@ -0,0 +1,79 @@
import { COMMENTS_MAX_LENGTH } from '@constants'
import useTextarea from '@hooks/useTextarea'
import { CommentSavePayload } from '@service'
import { userAtom } from '@stores'
import React, { createRef, forwardRef, useImperativeHandle } from 'react'
import { useTranslation } from 'react-i18next'
import { useRecoilValue } from 'recoil'
export type EditCommentsType = {
clear: () => void
textValue: ValueType | ReadonlyArray<string>
}
export interface EditCommentsProps {
handleRegist: (comment: CommentSavePayload) => void
handleCancel: () => void
comment: CommentSavePayload
}
const EditComments = forwardRef<EditCommentsType, EditCommentsProps>(
(props: EditCommentsProps, ref) => {
const { handleRegist, handleCancel, comment } = props
const { t } = useTranslation()
const user = useRecoilValue(userAtom)
const commentContentRef = createRef<HTMLTextAreaElement>()
const { currentCount, clear, ...textarea } = useTextarea({
value: '',
currentCount: 0,
})
const handleRegistClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
const commentContent = textarea.value as string
if (commentContent.trim().length === 0) {
commentContentRef.current?.focus()
return
}
comment.commentContent = commentContent.trim()
handleRegist(comment)
handleCancel()
}
const handleCancelClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
handleCancel()
}
useImperativeHandle(ref, () => ({
clear,
textValue: textarea.value,
}))
return (
<div>
<div className="writeComment">
<h5>{user.userName}</h5>
<textarea
ref={commentContentRef}
placeholder={t('posts.reply_placeholder')}
{...textarea}
/>
<div className="currentCount">
<span>{currentCount}</span> /<span>{COMMENTS_MAX_LENGTH}</span>
</div>
<div className="upload">
<button onClick={handleRegistClick}>{t('label.button.reg')}</button>
<a href="#" onClick={handleCancelClick}>
{t('label.button.cancel')}
</a>
</div>
</div>
</div>
)
},
)
export { EditComments }

View File

@@ -0,0 +1,44 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { format as dateFormat } from '@libs/date'
import { userAtom } from '@stores'
import { useRecoilValue } from 'recoil'
import { CommentSavePayload } from '@service'
interface ViewCommentsProps {
handleDelete: (comment: CommentSavePayload) => void
comment: CommentSavePayload
}
const ViewComments = (props: ViewCommentsProps) => {
const { handleDelete, comment } = props
const { t } = useTranslation()
const user = useRecoilValue(userAtom)
return (
<>
<div className="userName">
<strong>{comment.createdName}</strong>
<span>
{dateFormat(new Date(comment.createdDate), 'yyyy-MM-dd HH:mm:ss')}
</span>
</div>
<div className="commentContent">
<div dangerouslySetInnerHTML={{ __html: comment.commentContent }} />
</div>
{user && user.userId === comment.createdBy ? (
<a
href="#"
onClick={event => {
event.preventDefault()
handleDelete(comment)
}}
>
{t('label.button.delete')}
</a>
) : null}
</>
)
}
export { ViewComments }

View File

@@ -0,0 +1,2 @@
export * from './EditComments'
export * from './CommentsList'

View File

@@ -0,0 +1,110 @@
import { Typography } from '@material-ui/core'
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 { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import CheckCircleOutlineOutlinedIcon from '@material-ui/icons/CheckCircleOutlineOutlined'
import ErrorOutlineOutlinedIcon from '@material-ui/icons/ErrorOutlineOutlined'
import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'
import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined'
import { Color } from '@material-ui/lab/Alert'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
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,121 @@
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 CustomConfirmPrpps extends DialogProps {
title?: string
severity?: Color
contentText?: string | string[]
handleConfirm: () => void
handleCancel: () => void
buttonText?: string
buttonProps?: ButtonProps
}
const CustomConfirm = (props: CustomConfirmPrpps) => {
const {
open,
handleConfirm,
handleCancel,
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>
<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={handleConfirm}
color="primary"
autoFocus
{...buttonProps}
>
{buttonText || t('label.button.confirm')}
</Button>
<Button
variant="outlined"
onClick={handleCancel}
color="primary"
autoFocus
{...buttonProps}
>
{buttonText || t('label.button.cancel')}
</Button>
</DialogActions>
</Dialog>
)
}
export default CustomConfirm

View File

@@ -0,0 +1,20 @@
import React from 'react'
import 'swiper/components/pagination/pagination.min.css'
import SwiperCore, { Autoplay, Pagination } from 'swiper/core'
import { Swiper } from 'swiper/react'
// Import Swiper styles
import 'swiper/swiper.min.css'
// install Swiper modules
SwiperCore.use([Pagination, Autoplay])
interface CustomSwiperProps extends Swiper {
children: React.ReactNode[]
}
const CustomSwiper = (props: CustomSwiperProps) => {
const { children, ...rest } = props
return <Swiper {...rest}>{children && children.map(item => item)}</Swiper>
}
export default CustomSwiper

View File

@@ -0,0 +1,227 @@
import AttachList from '@components/AttachList'
import { BottomButtons, IButtons } from '@components/Buttons'
import Editor from '@components/Editor'
import Upload, { UploadType } from '@components/Upload'
import ValidationAlert from '@components/ValidationAlert'
import { DLWrapper } from '@components/WriteDLFields'
import { EDITOR_MAX_LENGTH } from '@constants'
import Divider from '@material-ui/core/Divider'
import Hidden from '@material-ui/core/Hidden'
import { BoardFormContext } from '@pages/board/[skin]/[board]/edit/[id]'
import { IPostsForm, UploadInfoReqeust } from '@service'
import { getTextLength } from '@utils'
import produce from 'immer'
import { useRouter } from 'next/router'
import React, { useContext, useMemo, useRef } from 'react'
import { Controller, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { EditFormProps } from '.'
type NormalEditFormProps = EditFormProps
const NormalEditForm = (props: NormalEditFormProps) => {
const router = useRouter()
const { t } = useTranslation()
const uploadRef = useRef<UploadType>()
const { post, board, attachList, setPostDataHandler, setAttachListHandler } =
useContext(BoardFormContext)
// form hook
const methods = useForm<IPostsForm>({
defaultValues: {
postsTitle: props.post?.postsTitle || '',
postsContent: props.post?.postsContent || '',
attachmentCode: props.post?.attachmentCode || '',
},
})
const {
control,
handleSubmit,
formState: { errors },
} = methods
const handleFormSubmit = async (data: IPostsForm) => {
if (board.uploadUseAt) {
const isUpload = await uploadRef.current.isModified(attachList)
if (isUpload) {
const info: UploadInfoReqeust = {
entityName: 'posts',
entityId: board.boardNo?.toString(),
}
// 업로드 및 저장
const result = await uploadRef.current.upload(info, attachList)
if (result) {
if (result !== 'no attachments' && result !== 'no update list') {
data = produce(data, draft => {
draft.attachmentCode = result
})
}
}
}
}
setPostDataHandler(data)
}
const bottomButtons = useMemo(
(): IButtons[] => [
{
id: 'board-edit-save',
title: t('label.button.save'),
href: '',
className: 'blue',
handleClick: handleSubmit(handleFormSubmit),
},
{
id: 'board-edit-list',
title: t('label.button.list'),
href: `/board/${router.query.skin}/${router.query.board}`,
},
],
[router.query.board, router.query.skin],
)
return (
<>
<form>
<div className="write">
<Controller
control={control}
name="postsTitle"
render={({ field, fieldState }) => (
<DLWrapper
title={t('posts.posts_title')}
className="inputTitle"
required
error={fieldState.error}
>
<input
type="text"
value={field.value}
onChange={field.onChange}
placeholder={t('posts.posts_title')}
/>
</DLWrapper>
)}
defaultValue=""
rules={{ required: true }}
/>
{board.uploadUseAt && (
<dl>
<dt>{t('common.attachment')}</dt>
<dd>
<Upload
ref={uploadRef}
multi
uploadLimitCount={board.uploadLimitCount}
uploadLimitSize={board.uploadLimitSize}
attachmentCode={post.attachmentCode}
attachData={attachList}
/>
<Divider variant="fullWidth" />
{attachList && (
<AttachList
data={attachList}
setData={setAttachListHandler}
/>
)}
</dd>
</dl>
)}
{board.editorUseAt ? (
<>
<Hidden smUp>
<Controller
control={control}
name="postsContent"
render={({ field }) => (
<Editor
contents={field.value}
setContents={field.onChange}
/>
)}
rules={{ required: true }}
defaultValue=""
/>
{errors.postsContent && (
<ValidationAlert
fieldError={errors.postsContent}
label={t('posts.posts_content')}
/>
)}
</Hidden>
<Hidden xsDown>
<dl>
<dt className="import">{t('posts.posts_content')}</dt>
<dd>
<div>
<Controller
control={control}
name="postsContent"
render={({ field }) => (
<Editor
contents={field.value}
setContents={field.onChange}
/>
)}
rules={{ required: true }}
defaultValue=""
/>
{errors.postsContent && (
<ValidationAlert
fieldError={errors.postsContent}
label={t('posts.posts_content')}
/>
)}
</div>
</dd>
</dl>{' '}
</Hidden>
</>
) : (
<dl>
<dt className="import">{t('posts.posts_content')}</dt>
<dd>
<Controller
control={control}
name="postsContent"
render={({ field }) => (
<>
<div>
<textarea {...field} />
</div>
<div>
<p className="byte">
<span> {getTextLength(field.value, 'char')} </span> /{' '}
{EDITOR_MAX_LENGTH}
</p>
</div>
</>
)}
defaultValue=""
rules={{ required: true, maxLength: EDITOR_MAX_LENGTH }}
/>
{errors.postsContent && (
<ValidationAlert
fieldError={errors.postsContent}
target={[EDITOR_MAX_LENGTH]}
label={t('posts.posts_content')}
/>
)}
</dd>
</dl>
)}
</div>
</form>
<BottomButtons handleButtons={bottomButtons} />
</>
)
}
export { NormalEditForm }

View File

@@ -0,0 +1,177 @@
import React, { useContext, useMemo, useRef } from 'react'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import { Controller, useForm } from 'react-hook-form'
import AttachList from '@components/AttachList'
import { BottomButtons, IButtons } from '@components/Buttons'
import Editor from '@components/Editor'
import Upload, { UploadType } from '@components/Upload'
import ValidationAlert from '@components/ValidationAlert'
import Divider from '@material-ui/core/Divider'
import { BoardFormContext } from '@pages/board/[skin]/[board]/edit/[id]'
import { IPostsForm } from '@service'
import { DLWrapper } from '@components/WriteDLFields'
import { EDITOR_MAX_LENGTH } from '@constants'
import { getTextLength } from '@utils'
import { userAtom } from '@stores'
import { useRecoilValue } from 'recoil'
import { EditFormProps } from '.'
type QnAEditFormProps = EditFormProps
const QnAEditForm = (props: QnAEditFormProps) => {
const router = useRouter()
const uploadRef = useRef<UploadType>()
const { post, board, attachList, setPostDataHandler, setAttachListHandler } =
useContext(BoardFormContext)
// form hook
const {
control,
handleSubmit,
formState: { errors },
} = useForm<IPostsForm>()
const { t, i18n } = useTranslation()
const user = useRecoilValue(userAtom)
const handleFormSubmit = (data: IPostsForm) => {
setPostDataHandler(data)
}
const bottomButtons = useMemo(
(): IButtons[] => [
{
id: 'board-edit-save',
title: t('label.button.save'),
href: '',
className: 'blue',
handleClick: handleSubmit(handleFormSubmit),
},
{
id: 'board-edit-list',
title: t('label.button.list'),
href: `/board/${router.query['skin']}/${router.query['board']}`,
},
],
[i18n],
)
return (
<>
<form>
<div className="write">
<Controller
control={control}
name="postsTitle"
render={({ field, fieldState }) => (
<DLWrapper
title={t('posts.qna_title')}
className="inputTitle"
required={true}
error={fieldState.error}
>
<input
type="text"
value={field.value}
onChange={field.onChange}
placeholder={t('posts.qna_title')}
/>
</DLWrapper>
)}
defaultValue={''}
rules={{ required: true }}
/>
<DLWrapper title={t('common.written_by')} required={true}>
<input type="text" value={user.userName} readOnly />
</DLWrapper>
{board.uploadUseAt && (
<dl>
<dt>{t('common.attachment')}</dt>
<dd>
<Upload
ref={uploadRef}
multi
uploadLimitCount={board.uploadLimitCount}
uploadLimitSize={board.uploadLimitSize * 1024 * 1024}
attachmentCode={post.attachmentCode}
attachData={attachList}
/>
<Divider variant="fullWidth" />
{attachList && (
<AttachList
data={attachList}
setData={setAttachListHandler}
/>
)}
</dd>
</dl>
)}
<dl>
<dt className="import">{t('posts.qna_content')}</dt>
<dd>
{board.editorUseAt ? (
<>
<Controller
control={control}
name="postsContent"
render={({ field }) => (
<Editor
contents={field.value}
setContents={field.onChange}
/>
)}
rules={{ required: true }}
defaultValue={''}
/>
{errors.postsContent && (
<ValidationAlert
fieldError={errors.postsContent}
label={t('posts.posts_content')}
/>
)}
</>
) : (
<>
<Controller
control={control}
name="postsContent"
render={({ field }) => (
<>
<div>
<textarea {...field} />
</div>
<div>
<p className="byte">
<span> {getTextLength(field.value, 'char')} </span>{' '}
/ {EDITOR_MAX_LENGTH}
</p>
</div>
</>
)}
defaultValue={''}
rules={{ required: true, maxLength: EDITOR_MAX_LENGTH }}
/>
{errors.postsContent && (
<ValidationAlert
fieldError={errors.postsContent}
target={[EDITOR_MAX_LENGTH]}
label={t('posts.posts_content')}
/>
)}
</>
)}
</dd>
</dl>
</div>
</form>
<BottomButtons handleButtons={bottomButtons} />
</>
)
}
export { QnAEditForm }

View File

@@ -0,0 +1,9 @@
import { IPostsForm } from '@service'
export * from './NormalEditForm'
export * from './QnAEditForm'
export interface EditFormProps {
// handleSave: () => void
post: IPostsForm
}

View File

@@ -0,0 +1,63 @@
import Loader from '@components/Loader'
import React, { useEffect, useRef, useState } from 'react'
export interface IEditor {
contents: string
setContents: (data: string) => void
readonly?: boolean
}
const Editor = (props: IEditor) => {
const { contents, setContents, readonly = false } = props
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>
<div id="editor" className={readonly ? 'editor-readonly' : ''}>
<CKEditor
editor={ClassicEditor}
data={contents}
disabled={readonly}
config={{
ckfinder: {
uploadUrl: `/api/editor`,
},
isReadOnly: readonly,
}}
onReady={(editor: any) => {
console.info('editor is ready to use', editor)
}}
onChange={(event: any, editor: any) => {
let chanagedata = editor.getData()
setContents(chanagedata)
}}
onBlur={(event: any, editor: any) => {
console.info('Blur.', editor)
}}
onFocus={(event: any, editor: any) => {
console.info('Focus.', editor)
}}
/>
</div>
</div>
) : (
<Loader />
)}
</>
)
}
export default Editor

View File

@@ -0,0 +1,38 @@
import ActiveLink from '@components/ActiveLink'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { IErrorMessage } from '.'
const ErrorPage = (props: IErrorMessage) => {
const { title, message } = props
const { t } = useTranslation()
return (
<div id="container">
<div>
<section className="error">
<article>
<h2>{title} </h2>
<div>
<span>
{title}
<br />
{message}
</span>
</div>
</article>
<div className="btn_center">
<ActiveLink
href="prev"
children={t('label.button.prev')}
className="blue"
/>
<ActiveLink href="/" children={t('label.button.go_home')} />
</div>
</section>
</div>
</div>
)
}
export { ErrorPage }

View File

@@ -0,0 +1,38 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { IErrorMessage } from '.'
interface ErrorPopupProps extends IErrorMessage {
handlePopupClose: () => void
}
const ErrorPopup = (props: ErrorPopupProps) => {
const { title, message, handlePopupClose } = props
const { t } = useTranslation()
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
handlePopupClose()
}
return (
<>
<div className="errorPop">
<div>
<div>
<h4>{title}</h4>
<p>
{title}
<br />
{message}
</p>
</div>
<a href="#" onClick={handleClick}>
{t('label.button.close')}
</a>
</div>
</div>
</>
)
}
export { ErrorPopup }

View File

@@ -0,0 +1,73 @@
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ErrorPage } from './ErrorPage'
import { ErrorPopup } from './ErrorPopup'
interface CustomErrorPageProps {
title?: string
message?: string
statusCode?: number
isPopup?: boolean
handlePopupClose?: () => void
}
export interface IErrorMessage {
title: string
message: string
}
const CustomErrorPage = (props: CustomErrorPageProps) => {
const {
title,
message,
statusCode,
isPopup = false,
handlePopupClose,
} = props
const { t } = useTranslation()
const [errorMessageState, setErrorMessageState] = useState<IErrorMessage>({
title: t('err.title'),
message: t('err.default.message'),
})
useEffect(() => {
if (message) {
setErrorMessageState({
title: title || t('error.title'),
message,
})
return
}
if (statusCode === 404) {
setErrorMessageState({
title: '404 Not Found',
message: t('err.page.not.found'),
})
return
}
if (statusCode) {
setErrorMessageState({
...errorMessageState,
message: t('err.internal.server'),
})
}
}, [statusCode, message])
return (
<>
{isPopup ? (
<ErrorPopup
handlePopupClose={handlePopupClose}
{...errorMessageState}
/>
) : (
<ErrorPage {...errorMessageState} />
)}
</>
)
}
export default CustomErrorPage

View File

@@ -0,0 +1,64 @@
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from 'react'
export type OptionsType = {
value: ValueType
label: string
}
export type SelectType = {
selectedValue: ValueType
}
interface SelectBoxProps
extends React.DetailedHTMLProps<
React.SelectHTMLAttributes<HTMLSelectElement>,
HTMLSelectElement
> {
options: OptionsType[]
customHandleChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void
}
const SelectBox = forwardRef<SelectType, SelectBoxProps>(
(props: SelectBoxProps, ref) => {
const { options, customHandleChange, ...rest } = props
const [selectedState, setSelectedState] =
useState<ValueType | undefined>(undefined)
useEffect(() => {
if (options.length > 0) {
setSelectedState(options[0].value)
}
}, [options])
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
if (customHandleChange) {
customHandleChange(e)
}
setSelectedState(e.target.value)
}
useImperativeHandle(ref, () => ({
selectedValue: selectedState,
}))
return (
<select onChange={handleChange} {...rest}>
{options &&
options.map(item => (
<option key={`selectbox-${item.value}`} value={item.value}>
{item.label}
</option>
))}
</select>
)
},
)
export { SelectBox }

View File

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

View File

@@ -0,0 +1,67 @@
import { Hidden } from '@material-ui/core'
import { currentMenuStateAtom, flatMenusSelect } from '@stores'
import { translateToLang } from '@utils'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRecoilValue } from 'recoil'
import { LayoutProps } from '.'
import Breadcrumb from './Breadcrumb'
import SideBar from './SideBar'
interface BodyProps extends LayoutProps {}
const Body = (props: BodyProps) => {
const { children } = props
const { i18n } = useTranslation()
const currentMenu = useRecoilValue(currentMenuStateAtom)
const flatMenus = useRecoilValue(flatMenusSelect)
const [titleState, setTitleState] =
useState<{
parent: string | undefined
current: string | undefined
}>(undefined)
useEffect(() => {
if (currentMenu) {
const parent = flatMenus.find(item => item.id === currentMenu.parentId)
if (!parent) {
setTitleState({
parent: translateToLang(i18n.language, currentMenu),
current: undefined,
})
return
}
setTitleState({
parent: translateToLang(i18n.language, parent),
current: translateToLang(i18n.language, currentMenu),
})
}
}, [currentMenu])
return (
<div id="container">
<div>
<Hidden smDown>
<SideBar />
</Hidden>
<section>
<article className="rocation">
{titleState && <h2>{titleState.parent}</h2>}
<Breadcrumb />
</article>
<article>
{titleState && titleState.current && <h3>{titleState.current}</h3>}
{children}
</article>
</section>
</div>
</div>
)
}
export default Body

View File

@@ -0,0 +1,68 @@
import { currentMenuStateAtom, flatMenusSelect, ISideMenu } from '@stores'
import { translateToLang } from '@utils'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRecoilValue } from 'recoil'
interface IBreadcrumb {
id: ValueType
name: string
}
const Breadcrumb = () => {
const currentMenu = useRecoilValue(currentMenuStateAtom)
const flatMenus = useRecoilValue(flatMenusSelect)
const { t, i18n } = useTranslation()
const [breadState, setBreadState] = useState<IBreadcrumb[]>(undefined)
useEffect(() => {
if (currentMenu) {
const nodes: IBreadcrumb[] = []
const arr = flatMenus.slice(
0,
flatMenus.findIndex(item => item.id === currentMenu.id) + 1,
)
nodes.push({
id: currentMenu.id,
name: translateToLang(i18n.language, currentMenu),
})
arr.reverse().some((item: ISideMenu) => {
if (item.level < currentMenu.level) {
nodes.push({
id: item.id,
name: translateToLang(i18n.language, item),
})
}
if (item.level === 1) {
return true
}
})
nodes.push({
id: 'home',
name: '홈',
})
const bread: IBreadcrumb[] = nodes.reverse().slice()
setBreadState(bread)
}
}, [currentMenu])
return (
<>
<ul>
{breadState &&
breadState.map(item => (
<li key={`bread-li-${item.id}`}>{item.name}</li>
))}
</ul>
</>
)
}
export default Breadcrumb

View File

@@ -0,0 +1,109 @@
import ActiveLink from '@components/ActiveLink'
import { ASSET_PATH } from '@constants/env'
import Hidden from '@material-ui/core/Hidden'
import { ISideMenu, menuStateAtom } from '@stores'
import { translateToLang } from '@utils'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRecoilValue } from 'recoil'
const Footer = () => {
const { i18n } = useTranslation()
const menus = useRecoilValue(menuStateAtom)
const [bottom, setBottom] = useState<ISideMenu>(undefined)
useEffect(() => {
if (menus) {
setBottom(menus.find(item => item.menuType === 'bottom'))
}
}, [menus])
return (
<>
<footer>
<div>
<ul>
{bottom &&
bottom.children.map(item => (
<li key={`bottom-li-${item.id}`}>
<ActiveLink
href={item.urlPath}
children={translateToLang(i18n.language, item)}
/>
</li>
))}
</ul>
<Hidden xsDown>
<div>
<div>
<dl>
<dt></dt>
<dd>
<a href="mailto:egovframesupport@gmail.com">
egovframesupport@gmail.com
</a>
</dd>
</dl>
<dl>
<dt></dt>
<dd>
<a href="tel:1566-3598">1566-3598(070-4448-2678)</a>
</dd>
</dl>
</div>
<div>
<dl>
<dt></dt>
<dd>
<a href="tel:070-4448-3673">070-4448-3673</a>
</dd>
</dl>
<dl>
<dt></dt>
<dd>
<a href="tel:070-4448-2674">070-4448-2674</a>
</dd>
</dl>
</div>
<p>
Copyright (C) 2021 Ministry of the Interior and Safety.
<br className="hidden" /> All Right Reserved.
</p>
</div>
<span>
<ActiveLink
href="https://www.mois.go.kr"
title="행정안전부 홈페이지로 이동합니다"
children={
<img
src={`${ASSET_PATH}/images/layout/logo_mois.png`}
alt="행정안전부"
/>
}
/>
<ActiveLink
href="https://www.nia.or.kr"
title="한국지능정보사회진흥원 홈페이지로 이동합니다"
children={
<img
src={`${ASSET_PATH}/images/layout/logo_nia.png`}
alt="한국지능정보사회진흥원"
/>
}
/>
</span>
</Hidden>
<Hidden smUp>
<p className="mobCopy">
(C) &nbsp;&nbsp; All Rights Reserved.
</p>
</Hidden>
</div>
</footer>
</>
)
}
export default Footer

View File

@@ -0,0 +1,109 @@
import ActiveLink from '@components/ActiveLink'
import Sitemap from '@components/Sitemap'
import { DEFAULT_APP_NAME } from '@constants'
import { ASSET_PATH } from '@constants/env'
import Hidden from '@material-ui/core/Hidden'
import { currentMenuStateAtom, menuStateAtom, userAtom } from '@stores'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRecoilValue } from 'recoil'
const Header = () => {
const { t, i18n } = useTranslation()
const menus = useRecoilValue(menuStateAtom)
const currentMenu = useRecoilValue(currentMenuStateAtom)
const user = useRecoilValue(userAtom)
const [sitemapState, setSitemapState] = useState<boolean>(false)
useEffect(() => {
setSitemapState(false)
}, [currentMenu])
const handleSitemap = (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault()
setSitemapState(!sitemapState)
}
return (
<>
<header>
<div>
<h1>
<Hidden smUp>
<ActiveLink
href="/"
children={
<img
src={`${ASSET_PATH}/images/layout/h1_logo_mob.png`}
alt={DEFAULT_APP_NAME}
/>
}
/>
</Hidden>
<Hidden xsDown>
<ActiveLink
href="/"
children={
<img
src={`${ASSET_PATH}/images/layout/h1_logo.png`}
alt={DEFAULT_APP_NAME}
/>
}
/>
</Hidden>
</h1>
<Hidden xsDown>
<nav>
<ul>
{menus &&
menus
.filter(item => item.isShow)
.map(item => (
<li key={`li-header-${item.id}`}>
<ActiveLink
key={`header-${item.id}`}
href={
item.children.length > 0
? item.children[0].urlPath
: item.urlPath
}
children={
i18n.language === 'ko' ? item.korName : item.engName
}
/>
</li>
))}
</ul>
</nav>
</Hidden>
<div className={`sitemap ${sitemapState ? 'on' : ''}`}>
{user ? (
<ActiveLink href="/auth/logout" children={t('common.logout')} />
) : (
<>
<ActiveLink
href="/auth/login"
className="login"
children={t('common.login')}
/>
<ActiveLink href="/auth/join" children={t('common.join')} />
</>
)}
<div>
<a href="#" className="btn" onClick={handleSitemap}>
{t('common.sitemap')}
</a>
{sitemapState && <Sitemap />}
</div>
</div>
</div>
</header>
</>
)
}
export default Header

View File

@@ -0,0 +1,22 @@
import React from 'react'
import { LayoutProps } from '.'
interface NoLeftBodyProps extends LayoutProps {}
const NoLeftBody = (props: NoLeftBodyProps) => {
const { children, main } = props
return (
<>
{main ? (
<div id="main">{children}</div>
) : (
<div id="container">
<div>{children}</div>
</div>
)}
</>
)
}
export default NoLeftBody

View File

@@ -0,0 +1,37 @@
import React from 'react'
import { useRecoilValue } from 'recoil'
import { currentMenuStateAtom, sideMenuSelect } from '@stores'
import ActiveLink from '@components/ActiveLink'
import { useTranslation } from 'react-i18next'
import { translateToLang } from '@utils'
const SideBar = () => {
const currentMenu = useRecoilValue(currentMenuStateAtom)
const menus = useRecoilValue(sideMenuSelect)
const { i18n } = useTranslation()
return (
<>
{menus && (
<nav>
<ul>
{menus.map(item => (
<li
key={`sidebar-li-${item.id}`}
className={`${item.id === currentMenu.id ? 'on' : null}`}
>
<ActiveLink
key={`sidebar-li-a-${item.id}`}
href={item.urlPath}
children={translateToLang(i18n.language, item)}
/>
</li>
))}
</ul>
</nav>
)}
</>
)
}
export default SideBar

View File

@@ -0,0 +1,23 @@
import React from 'react'
import Body from './Body'
import Footer from './Footer'
import Header from './Header'
import NoLeftBody from './NoLeftBody'
export interface LayoutProps {
children: React.ReactNode
main?: boolean
isLeft?: boolean
}
const Layout = (props: LayoutProps) => {
return (
<div id="wrap">
<Header />
{props.isLeft ? <Body {...props} /> : <NoLeftBody {...props} />}
<Footer />
</div>
)
}
export default Layout

View File

@@ -0,0 +1,26 @@
import CircularProgress from '@material-ui/core/CircularProgress'
import Container from '@material-ui/core/Container'
import { makeStyles, Theme } 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,431 @@
import ActiveLink from '@components/ActiveLink'
import CustomSwiper from '@components/CustomSwiper'
import { LOAD_IMAGE_URL } from '@constants'
import { ASSET_PATH, SERVER_API_URL } from '@constants/env'
import { convertStringToDateFormat } from '@libs/date'
import { IBoard, IMainItem } from '@service'
import { userAtom } from '@stores'
import { useRouter } from 'next/router'
import { useSnackbar } from 'notistack'
import React, { useCallback, useEffect, useState } from 'react'
import { useRecoilValue } from 'recoil'
import { SwiperSlide } from 'swiper/react'
import { bannerTypeCodes, MainProps } from '.'
interface MainLGProps extends MainProps {
reserveItems: IMainItem
}
const MainLG = (props: MainLGProps) => {
const { banners, boards, reserveItems } = props
const router = useRouter()
const user = useRecoilValue(userAtom)
const { enqueueSnackbar } = useSnackbar()
const [activeNotice, setActiveNotice] = useState<number>(
Number(Object.keys(boards)[0]),
)
const [activeBoard, setActiveBoard] = useState<number>(
Number(Object.keys(boards)[2]),
)
const [notice, setNotice] = useState(undefined)
const [board, setBoard] = useState(undefined)
const [mainBanners, setMainBanners] = useState(undefined)
const [items, setItems] = useState(undefined)
const [activeItem, setAcitveItem] = useState<string>(
Object.keys(reserveItems)[0],
)
//예약 물품
useEffect(() => {
if (reserveItems) {
const active = reserveItems[activeItem]
setItems(
active?.map(reserveItem => (
<SwiperSlide key={`reserve-item-${reserveItem.reserveItemId}`}>
<h5>{reserveItem.categoryName}</h5>
<dl>
<dt></dt>
<dd>{reserveItem.reserveItemName}</dd>
<p>{`${convertStringToDateFormat(
reserveItem.startDate,
'yyyy-MM-dd',
)} ~ ${convertStringToDateFormat(
reserveItem.endDate,
'yyyy-MM-dd',
)}`}</p>
<ActiveLink
handleActiveLinkClick={() => {
if (!reserveItem.isPossible) {
return
}
if (user == null) {
enqueueSnackbar('로그인이 필요합니다.', {
variant: 'warning',
})
return
}
router.push(
`/reserve/${reserveItem.categoryId}/${reserveItem.reserveItemId}`,
)
}}
className={reserveItem.isPossible ? 'possible' : ''}
href="#"
>
{reserveItem.isPossible ? '예약 가능' : '예약 불가'}
</ActiveLink>
</dl>
</SwiperSlide>
)),
)
}
}, [reserveItems, activeItem])
// 메인 배너
useEffect(() => {
if (banners) {
setMainBanners(
banners[bannerTypeCodes[0]]?.map((b, i) => {
return (
<SwiperSlide
key={`main-banner-${b.bannerNo}`}
style={{
backgroundImage: `url(${SERVER_API_URL}${LOAD_IMAGE_URL}${b.uniqueId})`,
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<div className="slide-title">
<p>{b.bannerTitle}</p>
</div>
<div className="slide-content">
<p>{b.bannerContent}</p>
</div>
<a
href={b.urlAddr}
target={b.newWindowAt ? '_blank' : '_self'}
rel="noreferrer"
>
</a>
</SwiperSlide>
)
}),
)
}
}, [banners])
//boards
useEffect(() => {
if (boards) {
const active = boards[activeBoard]
setBoard(drawDivs(active))
}
}, [boards, activeBoard])
useEffect(() => {
if (boards) {
const active = boards[activeNotice]
setNotice(drawDivs(active))
}
}, [boards, activeNotice])
//board 아이템 draw
const drawDivs = useCallback(
(board: IBoard) => {
return (
board && (
<div key={`board-div-${board.boardNo}`}>
{board.posts.map(post => (
<dl key={`posts-dl-${post.postsNo}`}>
<dt>
<ActiveLink
href={`/board/${board.skinTypeCode}/${board.boardNo}/view/${post.postsNo}`}
>
{post.isNew ? <span className="newIcon">NEW</span> : null}
{post.postsTitle}
</ActiveLink>
</dt>
<dd>
<span>
{convertStringToDateFormat(post.createdDate, 'yyyy-MM-dd')}
</span>
</dd>
</dl>
))}
<ActiveLink
key={`board-more-${board.boardNo}`}
href={`/board/${board.skinTypeCode}/${board.boardNo}`}
>
</ActiveLink>
</div>
)
)
},
[boards, activeBoard, activeNotice],
)
// 게시판 목록 draw
const drawBoardList = () => {
const boardNos = Object.keys(boards)
let ul = []
let children = []
boardNos.map((no, idx) => {
const title = React.createElement(
'h4',
{
key: `notice-h4-${no}`,
className:
Number(no) === activeBoard || Number(no) === activeNotice
? 'on'
: '',
onClick: () => {
handleBoardClick(Number(no))
},
},
boards[no].boardName,
)
children.push(
React.createElement(
'li',
{ key: `notice-li-${no}` },
<>
{title}
{Number(no) === activeBoard
? board
: Number(no) === activeNotice
? notice
: null}
</>,
),
)
if ((idx + 1) % 2 === 0) {
ul.push(
React.createElement(
'ul',
{
key: `notice-ul-${no}`,
},
children,
),
)
children = []
}
})
return React.createElement('div', null, ul)
}
const handleBoardClick = (no: number) => {
if (no > 2) {
setActiveBoard(no)
} else {
setActiveNotice(no)
}
}
const handleItemClick = (key: string) => {
setAcitveItem(key)
}
return (
<>
<div className="slide">
{mainBanners && (
<CustomSwiper
slidesPerView={1}
spaceBetween={30}
pagination={{
clickable: true,
}}
centeredSlides
loop
autoplay={{
delay: 5000,
disableOnInteraction: false,
}}
className="slideBox"
>
{mainBanners}
</CustomSwiper>
)}
<div className="reservBox">
<ul>
{reserveItems &&
Object.keys(reserveItems).map(key => (
<li
key={`reserve-items-li-${key}`}
className={`box ${activeItem === key ? 'on' : ''}`}
>
<ActiveLink
href="#"
handleActiveLinkClick={() => {
handleItemClick(key)
}}
>
{key}
</ActiveLink>
</li>
))}
<div>
{items && (
<CustomSwiper
slidesPerView={1}
spaceBetween={70}
pagination={{
clickable: true,
}}
centeredSlides
loop
autoplay={{
delay: 5000,
disableOnInteraction: false,
}}
className="reserve"
>
{items}
</CustomSwiper>
)}
</div>
</ul>
</div>
</div>
<div className="guide">
<h3>&amp;</h3>
<ul>
<li>
<dl>
<dt></dt>
<dd>
<br />
</dd>
</dl>
<div>
<img
src={`${ASSET_PATH}/images/main/main_icon01.png`}
alt="개발환경"
/>
</div>
<div className="downIcon">
<a href="#"></a>
<a href="#"></a>
</div>
</li>
<li>
<dl>
<dt></dt>
<dd>
<br />
</dd>
</dl>
<div>
<img
src={`${ASSET_PATH}/images/main/main_icon02.png`}
alt="실행환경"
/>
</div>
<div className="downIcon">
<a href="#"></a>
<a href="#"></a>
</div>
</li>
<li>
<dl>
<dt></dt>
<dd>
<br />
</dd>
</dl>
<div>
<img
src={`${ASSET_PATH}/images/main/main_icon03.png`}
alt="운영환경"
/>
</div>
<div className="downIcon">
<a href="#"></a>
<a href="#"></a>
</div>
</li>
<li>
<dl>
<dt></dt>
<dd>
<br />
</dd>
</dl>
<div>
<img
src={`${ASSET_PATH}/images/main/main_icon04.png`}
alt="공통컴포넌트"
/>
</div>
<div className="downIcon">
<a href="#"></a>
<a href="#"></a>
</div>
</li>
</ul>
</div>
<div className="notice">{drawBoardList()}</div>
<div className="supportService">
<div>
<h3 className="blind"></h3>
<ul>
<li>
<dl>
<dt></dt>
<dd> </dd>
</dl>
<a href="#"></a>
</li>
<li>
<dl>
<dt></dt>
<dd> </dd>
</dl>
<a href="#"></a>
</li>
<li>
<dl>
<dt></dt>
<dd> </dd>
</dl>
<a href="#"></a>
</li>
</ul>
</div>
<div>
<h3 className="blind"> </h3>
<div>
<a href="#">
<br />
SW
</a>
</div>
</div>
</div>
</>
)
}
export { MainLG }

View File

@@ -0,0 +1,301 @@
import ActiveLink from '@components/ActiveLink'
import CustomSwiper from '@components/CustomSwiper'
import { LOAD_IMAGE_URL } from '@constants'
import { ASSET_PATH, SERVER_API_URL } from '@constants/env'
import { format as dateFormat } from '@libs/date'
import { escapeHtmlNl } from '@utils'
import { useRouter } from 'next/router'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SwiperSlide } from 'swiper/react'
import { bannerTypeCodes, MainProps } from '.'
const MainSM = ({ banners, boards }: MainProps) => {
const { t } = useTranslation()
const router = useRouter()
const [activeBoard, setActiveBoard] = useState<string>(Object.keys(boards)[0])
const [mainBanners, setMainBanners] = useState(undefined)
const [board, setBoard] = useState(undefined)
// 메인 배너
useEffect(() => {
if (banners) {
setMainBanners(
banners[bannerTypeCodes[0]]?.map((b, i) => {
return (
<SwiperSlide
key={`main-banner-${b.bannerNo}`}
className={`slide`}
style={{
backgroundImage: `url(${SERVER_API_URL}${LOAD_IMAGE_URL}${b.uniqueId})`,
backgroundRepeat: 'no-repeat',
backgroundSize: '100% 100%',
}}
>
<div className="slide-title">
<p>{b.bannerTitle}</p>
</div>
<div className="slide-content">
<p>{b.bannerContent}</p>
</div>
<a
href={b.urlAddr}
target={b.newWindowAt ? '_blank' : '_self'}
rel="noreferrer"
>
</a>
</SwiperSlide>
)
}),
)
}
}, [banners])
//board
useEffect(() => {
if (boards) {
setBoard(
boards[activeBoard]?.posts?.map(post => {
return (
<dl key={post.postsNo}>
<dt
onClick={() => {
handlePostView(
boards[activeBoard]?.skinTypeCode,
post.boardNo,
post.postsNo,
)
}}
>
{post.postsTitle}
</dt>
<dd>
<p>{escapeHtmlNl(post.postsContent)}</p>
<span>
{dateFormat(
new Date(post.createdDate as string),
'yyyy-MM-dd',
)}
</span>
<ActiveLink
href="#"
handleActiveLinkClick={() => {
handlePostView(
boards[activeBoard].skinTypeCode,
post.boardNo,
post.postsNo,
)
}}
>
{t('posts.see_more')}
</ActiveLink>
</dd>
</dl>
)
}),
)
}
}, [boards, activeBoard])
// 게시판 탭 포커스
const handleBoardFocus = boardNo => {
setActiveBoard(boardNo)
}
// 게시물 더보기
const handlePostView = (skinTypeCode, boardNo, postsNo) => {
router.push(`/board/${skinTypeCode}/${boardNo}/view/${postsNo}`)
}
return (
<>
{mainBanners && (
<CustomSwiper
slidesPerView={1}
spaceBetween={30}
pagination={{
clickable: true,
}}
centeredSlides
loop
breakpoints={{
641: {
slidesPerView: 3,
},
}}
autoplay={{
delay: 5000,
disableOnInteraction: false,
}}
>
{mainBanners}
</CustomSwiper>
)}
{Object.keys(boards).length > 0 && (
<div className="notice">
<ul>
{Object.keys(boards).map(boardNo => {
return !boards[boardNo] ? null : (
<li key={`notice-li-${boardNo}`}>
<h4 className={`${activeBoard === boardNo ? 'on' : ''}`}>
<a
href="#"
onFocus={() => {
handleBoardFocus(boardNo)
}}
onMouseOver={() => {
handleBoardFocus(boardNo)
}}
onClick={event => {
event.preventDefault()
handleBoardFocus(boardNo)
}}
>
{boards[boardNo].boardName}
</a>
</h4>
{board && <div>{board}</div>}
</li>
)
})}
</ul>
</div>
)}
<div className="guide">
<h3>&amp;</h3>
<ul>
<li>
<figure>
<img
src={`${ASSET_PATH}/images/main/main_icon01.png`}
alt="개발환경"
/>
<figcaption></figcaption>
</figure>
<p>
<br />
</p>
<div>
<a href="#"></a>
<a href="#"></a>
</div>
</li>
<li>
<figure>
<img
src={`${ASSET_PATH}/images/main/main_icon02.png`}
alt="실행환경"
/>
<figcaption></figcaption>
</figure>
<p>
<br /> {' '}
</p>
<div>
<a href="#"></a>
<a href="#"></a>
</div>
</li>
<li>
<figure>
<img
src={`${ASSET_PATH}/images/main/main_icon03.png`}
alt="운영환경"
/>
<figcaption></figcaption>
</figure>
<p>
<br />
</p>
<div>
<a href="#"></a>
<a href="#"></a>
</div>
</li>
<li>
<figure>
<img
src={`${ASSET_PATH}/images/main/main_icon04.png`}
alt="공통컴포넌트"
/>
<figcaption></figcaption>
</figure>
<p>
<br />
</p>
<div>
<a href="#"></a>
<a href="#"></a>
</div>
</li>
</ul>
<div>
<div>
<h3></h3>
<ul>
<li>
<dl>
<dt></dt>
<dd>
<br />
</dd>
</dl>
<div className="small">
<a href="#"></a>
<a href="#"></a>
</div>
</li>
<li>
<dl>
<dt></dt>
<dd>
<br />
</dd>
</dl>
<div className="small">
<a href="#"></a>
<a href="#"></a>
</div>
</li>
<li>
<dl>
<dt></dt>
<dd>
<br />
</dd>
</dl>
<div className="small">
<a href="#"></a>
<a href="#"></a>
</div>
</li>
</ul>
</div>
<div>
<h3> </h3>
<div>
<a href="#">
<br />
SW
</a>
</div>
</div>
</div>
</div>
</>
)
}
export { MainSM }

View File

@@ -0,0 +1,15 @@
import { IMainBanner, IMainBoard } from '@service'
export * from './MainLG'
export * from './MainSM'
export interface MainProps {
banners: IMainBanner | null
boards: IMainBoard | null
}
//banner type (메인, 하단, 협력기업)
export const bannerTypeCodes = ['0001', '0002', '0003']
export const slideCount = 3 // 0: 전부
// 게시판 - 공지사항, 자료실, 묻고답하기, 자주묻는질문 순서
export const boardNos = [1, 2, 3, 4]

View File

@@ -0,0 +1,144 @@
import { BottomButtons, IButtons } from '@components/Buttons'
import { DLWrapper } from '@components/WriteDLFields'
import { format, isValidPassword } from '@utils'
import React, { createRef, useMemo } from 'react'
import { Controller, UseFormSetFocus } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { IUserPasswordForm, PasswordProps } from '.'
interface PasswordChangeProps extends PasswordProps {
handleChangePassword: () => void
setFocus: UseFormSetFocus<IUserPasswordForm>
currentPassword: string
}
const PasswordChange = (props: PasswordChangeProps) => {
const {
control,
handleChangePassword,
setFocus,
currentPassword,
handleList,
} = props
const { t } = useTranslation()
const newPasswordRef = createRef<HTMLInputElement>()
const handleKeyPress = (event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
setFocus('newPasswordConfirm')
}
}
const handleKeyPressConfirm = (event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
handleChangePassword()
}
}
const bottomButtons = useMemo(
(): IButtons[] => [
{
id: 'board-edit-save',
title: t('label.button.change'),
href: '',
className: 'blue',
handleClick: handleChangePassword,
},
{
id: 'board-edit-list',
title: t('label.button.cancel'),
href: ``,
handleClick: handleList,
},
],
[props, t],
)
return (
<div className="table_write01">
<span>{t('common.required_fields')}</span>
<div className="change">
<Controller
control={control}
name="newPassword"
render={({ field, fieldState }) => (
<DLWrapper
title={t('label.title.new_password')}
className="inputTitle"
required
error={fieldState.error}
>
<input
autoFocus
ref={newPasswordRef}
type="password"
value={field.value}
onChange={field.onChange}
placeholder={t('label.title.new_password')}
onKeyPress={handleKeyPress}
/>
<span>{t('label.text.password_format')}</span>
</DLWrapper>
)}
defaultValue=""
rules={{
required: true,
maxLength: {
value: 20,
message: format(t('valid.maxlength.format'), [20]),
},
validate: value => {
return (
(!isValidPassword(value) && (t('valid.password') as string)) ||
(currentPassword === value &&
(t('valid.user.password.notchange') as string)) ||
true
)
},
}}
/>
<Controller
control={control}
name="newPasswordConfirm"
render={({ field, fieldState }) => (
<DLWrapper
title={t('label.title.new_password_confirm')}
className="inputTitle"
required
error={fieldState.error}
>
<input
ref={field.ref}
type="password"
value={field.value}
onChange={field.onChange}
placeholder={t('label.title.new_password_confirm')}
onKeyPress={handleKeyPressConfirm}
/>
</DLWrapper>
)}
defaultValue=""
rules={{
required: true,
maxLength: {
value: 20,
message: format(t('valid.maxlength.format'), [20]),
},
validate: value => {
return (
(isValidPassword(value) &&
newPasswordRef.current?.value === value) ||
(t('valid.password.confirm') as string)
)
},
}}
/>
</div>
<BottomButtons handleButtons={bottomButtons} />
</div>
)
}
export { PasswordChange }

View File

@@ -0,0 +1,86 @@
import { BottomButtons, IButtons } from '@components/Buttons'
import { DLWrapper } from '@components/WriteDLFields'
import { format, isValidPassword } from '@utils'
import React, { useMemo } from 'react'
import { Controller } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { PasswordProps } from '.'
interface PasswordConfirmProps extends PasswordProps {
handleCheckPassword: () => void
}
const PasswordConfirm = (props: PasswordConfirmProps) => {
const { control, formState, handleCheckPassword, handleList } = props
const { t } = useTranslation()
const handleKeyPress = (event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
handleCheckPassword()
}
}
const bottomButtons = useMemo(
(): IButtons[] => [
{
id: 'board-edit-save',
title: t('label.button.confirm'),
href: '',
className: 'blue',
handleClick: handleCheckPassword,
},
{
id: 'board-edit-list',
title: t('label.button.cancel'),
href: ``,
handleClick: handleList,
},
],
[props, t],
)
return (
<div className="table_write01">
<span>{t('common.required_fields')}</span>
<div className="write">
<Controller
control={control}
name="currentPassword"
render={({ field, fieldState }) => (
<DLWrapper
title={t('user.password')}
className="inputTitle"
required
error={fieldState.error}
>
<input
autoFocus
type="password"
value={field.value}
onChange={field.onChange}
placeholder={t('user.password')}
onKeyPress={handleKeyPress}
/>
</DLWrapper>
)}
defaultValue=""
rules={{
required: true,
maxLength: {
value: 20,
message: format(t('valid.maxlength.format'), [20]),
},
validate: value => {
return isValidPassword(value) || (t('valid.password') as string)
},
}}
/>
</div>
<BottomButtons handleButtons={bottomButtons} />
</div>
)
}
export { PasswordConfirm }

View File

@@ -0,0 +1,33 @@
import { BottomButtons, IButtons } from '@components/Buttons'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
interface PasswordDoneProps {
handleList: () => void
}
const PasswordDone = ({ handleList }: PasswordDoneProps) => {
const { t } = useTranslation()
const bottomButtons = useMemo(
(): IButtons[] => [
{
id: 'board-edit-list',
title: t('label.button.first'),
href: ``,
handleClick: handleList,
},
],
[t],
)
return (
<article className="mypage">
<div className="message">
<span className="change">{t('label.text.user.password.modified')}</span>
</div>
<BottomButtons handleButtons={bottomButtons} />
</article>
)
}
export { PasswordDone }

View File

@@ -0,0 +1,17 @@
import { Control, FormState } from 'react-hook-form'
export * from './PasswordChange'
export * from './PasswordConfirm'
export * from './PasswordDone'
export interface IUserPasswordForm {
currentPassword: string
newPassword: string
newPasswordConfirm: string
}
export interface PasswordProps {
control: Control<any>
formState: FormState<any>
handleList: () => void
}

View File

@@ -0,0 +1,42 @@
import { BottomButtons, IButtons } from '@components/Buttons'
import { useRouter } from 'next/router'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
interface ReserveCompleteProps {
reserveId: string
}
const ReserveComplete = ({ reserveId }: ReserveCompleteProps) => {
const router = useRouter()
const { t } = useTranslation()
// 버튼
const bottomButtons = useMemo(
(): IButtons[] => [
{
id: 'item-confirm-button',
title: `${t('reserve')} ${t('label.button.confirm')}`,
href: `/user/reserve/${reserveId}`,
className: 'blue',
},
{
id: 'item-list-button',
title: t('label.button.list'),
href: `/reserve/${router.query.category}`,
},
],
[t, router, reserveId],
)
return (
<>
<div className="reserv">
<span>{t('reserve.msg.complete')}</span>
</div>
<BottomButtons handleButtons={bottomButtons} />
</>
)
}
export { ReserveComplete }

View File

@@ -0,0 +1,96 @@
import ValidationAlert from '@components/ValidationAlert'
import { defaultlocales } from '@libs/date'
import { useTranslation } from 'next-i18next'
import React, { useState } from 'react'
import DatePicker from 'react-datepicker'
import 'react-datepicker/dist/react-datepicker.css'
import { Controller } from 'react-hook-form'
import { ReserveEditFormProps } from '.'
interface ReserveDateRangeFieldProps extends ReserveEditFormProps {}
const dateFormat = 'yyyy-MM-dd'
const ReserveDateRangeField = (props: ReserveDateRangeFieldProps) => {
const { control, formState, required = false } = props
const { i18n } = useTranslation()
const [startDate, setStartDate] = useState<Date | null>(null)
const [endDate, setEndDate] = useState<Date | null>(null)
return (
<dl>
<dt className="import">
<br className="mb" />
()
</dt>
<dd>
<div className="dateRange">
<Controller
control={control}
name="reserveStartDate"
render={({ field, fieldState }) => (
<DatePicker
selected={startDate}
onChange={(
date: Date,
event: React.SyntheticEvent<any> | undefined,
) => {
setStartDate(date)
field.onChange(date)
}}
selectsStart
startDate={startDate}
endDate={endDate}
minDate={new Date()}
dateFormat={dateFormat}
locale={defaultlocales[i18n.language]}
className="calendar"
/>
)}
rules={{ required: required }}
/>
<span className="span"> ~ </span>
<Controller
control={control}
name="reserveEndDate"
render={({ field, fieldState }) => (
<DatePicker
selected={endDate}
onChange={(
date: Date,
event: React.SyntheticEvent<any> | undefined,
) => {
setEndDate(date)
field.onChange(date)
}}
selectsEnd
startDate={startDate}
endDate={endDate}
minDate={startDate}
dateFormat={dateFormat}
locale={defaultlocales[i18n.language]}
className="calendar"
/>
)}
rules={{ required: required }}
/>
</div>
{formState.errors.reserveStartDate && (
<ValidationAlert
fieldError={formState.errors.reserveStartDate}
label={'신청시작일'}
/>
)}
{formState.errors.reserveEndDate && (
<ValidationAlert
fieldError={formState.errors.reserveEndDate}
label={'신청종료일'}
/>
)}
</dd>
</dl>
)
}
export { ReserveDateRangeField }

View File

@@ -0,0 +1,384 @@
import { BottomButtons, IButtons } from '@components/Buttons'
import Upload, { UploadType } from '@components/Upload'
import { DLWrapper } from '@components/WriteDLFields'
import { DEFAULT_ERROR_MESSAGE } from '@constants'
import { convertStringToDateFormat } from '@libs/date'
import Backdrop from '@material-ui/core/Backdrop'
import CircularProgress from '@material-ui/core/CircularProgress'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import { IReserveComplete } from '@pages/reserve/[category]/[id]'
import {
IReserve,
IReserveItem,
ReserveSavePayload,
reserveService,
UploadInfoReqeust,
} from '@service'
import { errorStateSelector, userAtom } from '@stores'
import produce from 'immer'
import { useRouter } from 'next/router'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import 'react-datepicker/dist/react-datepicker.css'
import {
Control,
Controller,
FormProvider,
FormState,
useForm,
useWatch,
} from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { useRecoilValue, useSetRecoilState } from 'recoil'
import { ReserveDateRangeField } from './ReserveDateRangeField'
import ReserveEventSource from './ReserveEventSource'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
backdrop: {
zIndex: theme.zIndex.drawer + 1,
color: '#fff',
},
}),
)
interface ReserveEditProps {
reserveItem: IReserveItem
setComplete: React.Dispatch<React.SetStateAction<IReserveComplete>>
}
export interface ReserveEditFormProps {
control: Control<ReserveSavePayload>
formState: FormState<ReserveSavePayload>
required?: boolean
}
const ReserveEdit = (props: ReserveEditProps) => {
const classes = useStyles()
const { reserveItem, setComplete } = props
const router = useRouter()
const { t, i18n } = useTranslation()
const user = useRecoilValue(userAtom)
const setErrorState = useSetRecoilState(errorStateSelector)
const [loading, setLoading] = useState<boolean>(false)
const [currentInventoryQty, setCurrentInventoryQty] = useState<number | null>(
reserveItem.inventoryQty || null,
)
const [reserve, setReserve] = useState<IReserve | undefined>(undefined)
const [isEvent, setEvents] = useState<boolean>(false)
const methods = useForm<ReserveSavePayload>()
const { control, formState, setError, clearErrors, handleSubmit } = methods
const uploadRef = useRef<UploadType>()
const watchStartDate = useWatch({
control,
name: 'reserveStartDate',
})
const watchEndDate = useWatch({
control,
name: 'reserveEndDate',
})
const watchQty = useWatch({
control,
name: 'reserveQty',
})
useEffect(() => {
if (watchQty && currentInventoryQty) {
if (watchQty > currentInventoryQty) {
setError(
'reserveQty',
{ message: '현재가능수량보다 많습니다.' },
{ shouldFocus: true },
)
} else {
clearErrors('reserveQty')
}
}
}, [watchQty, currentInventoryQty])
useEffect(() => {
if (watchStartDate && watchEndDate) {
if (reserveItem.categoryId === 'equipment') {
getCountInventory()
}
}
}, [watchStartDate, watchEndDate])
const getCountInventory = useCallback(async () => {
try {
const result = await reserveService.getCountInventory(
reserveItem.reserveItemId,
convertStringToDateFormat(watchStartDate),
convertStringToDateFormat(watchEndDate),
)
if (result) {
setCurrentInventoryQty(result.data)
}
} catch (error) {
setErrorState({ error })
}
}, [reserveItem.reserveItemId, watchStartDate, watchEndDate])
const successCallback = useCallback(() => {
setComplete({
done: true,
reserveId: reserve.reserveId,
})
setLoading(false)
}, [reserve])
const errorCallback = useCallback(
(errors: any, attachmentCode: string) => {
setErrorState({ errors })
setLoading(false)
if (attachmentCode) {
uploadRef.current.rollback(attachmentCode)
}
},
[uploadRef],
)
/**
* 심사인 경우 저장
*/
const saveEvaluate = async (formData: ReserveSavePayload) => {
try {
const result = await reserveService.createAudit(formData)
if (result) {
successCallback()
} else {
errorCallback(
{ message: DEFAULT_ERROR_MESSAGE },
formData.attachmentCode,
)
}
} catch (error) {
errorCallback(error, formData.attachmentCode)
}
}
/**
* 실시간 & 선착순 인 경우 저장 후 이벤트 메세지까지 완료되었는지 확인
*/
const save = async (formData: ReserveSavePayload) => {
try {
const result = await reserveService.create(formData)
if (result) {
setReserve(result.data)
if (formData.categoryId === 'education') {
setEvents(true)
} else {
successCallback()
}
} else {
errorCallback(
{ message: DEFAULT_ERROR_MESSAGE },
formData.attachmentCode,
)
}
} catch (error) {
errorCallback(error, formData.attachmentCode)
}
}
const handleSavebefore = async (formData: ReserveSavePayload) => {
setLoading(true)
let attachmentCode = ''
try {
const info: UploadInfoReqeust = {
entityName: 'reserve',
entityId: '-1',
}
const result = await uploadRef.current?.upload(info)
if (result !== 'no attachments' && result !== 'no update list') {
attachmentCode = result
}
} catch (error) {
setErrorState({ error })
}
formData = produce(formData, draft => {
draft.reserveItemId = reserveItem.reserveItemId
draft.locationId = reserveItem.locationId
draft.categoryId = reserveItem.categoryId
draft.totalQty = reserveItem.totalQty
draft.reserveMethodId = reserveItem.reserveMethodId
draft.reserveMeansId = reserveItem.reserveMeansId
draft.operationStartDate = reserveItem.operationEndDate
draft.operationEndDate = reserveItem.operationEndDate
draft.requestStartDate = reserveItem.requestStartDate
draft.requestEndDate = reserveItem.requestEndDate
draft.isPeriod = reserveItem.isPeriod
draft.periodMaxCount = reserveItem.periodMaxCount
draft.attachmentCode = attachmentCode
draft.userId = user.userId
draft.userEmail = user.email
})
if (
reserveItem.reserveMeansId === 'realtime' &&
reserveItem.selectionMeansId === 'fcfs'
) {
save(formData)
} else {
saveEvaluate(formData)
}
}
// 버튼
const bottomButtons = useMemo((): IButtons[] => {
const buttons: IButtons[] = []
buttons.push({
id: 'item-edit-button',
title: t('reserve_item.request'),
href: '',
className: 'blue',
handleClick: handleSubmit(handleSavebefore),
})
buttons.push({
id: 'item-list-button',
title: t('label.button.list'),
href: `/reserve/${router.query.category}`,
})
return buttons
}, [t, router.query])
return (
<>
<FormProvider {...methods}>
<h4>{`${t('reserve')} ${t('common.information')}`}</h4>
<div className="view">
<span>{t('common.required_fields')}</span>
{reserveItem.categoryId === 'education' ? null : (
<ReserveDateRangeField
control={control}
formState={formState}
required={true}
/>
)}
{reserveItem.categoryId === 'space' ? null : (
<Controller
control={control}
name="reserveQty"
render={({ field, fieldState }) => (
<DLWrapper
title={`${t('reserve.request')} ${
reserveItem.categoryId === 'equipment'
? t('reserve.count')
: t('reserve.number_of_people')
}`}
required
error={fieldState.error}
>
<input
type="text"
value={field.value}
onChange={field.onChange}
title={`${t('reserve.request')} ${
reserveItem.categoryId === 'equipment'
? t('reserve.count')
: t('reserve.number_of_people')
}`}
/>
{reserveItem.categoryId === 'equipment' ? (
<span className="span">
{`* ${t(
'reserve.msg.possible_count',
)} : ${currentInventoryQty} `}
</span>
) : null}
</DLWrapper>
)}
defaultValue={0}
rules={{
required: true,
validate: value =>
value < currentInventoryQty || '현재 가능 수량보다 많습니다.',
}}
/>
)}
<Controller
control={control}
name="reservePurposeContent"
render={({ field, fieldState }) => (
<DLWrapper
title={`${t('reserve.request')} ${t('reserve.purpose')}`}
className="inputTitle"
required
error={fieldState.error}
>
<input
type="text"
value={field.value}
onChange={field.onChange}
placeholder={`${t('reserve.request')} ${t(
'reserve.purpose',
)}${t('msg.placeholder')}`}
/>
</DLWrapper>
)}
defaultValue=""
rules={{ required: true }}
/>
<Controller
control={control}
name="userContactNo"
render={({ field, fieldState }) => (
<DLWrapper
title={`${t('reserve.user')} ${t('common.contact')}`}
className="inputTitle"
required
error={fieldState.error}
>
<input
type="text"
value={field.value}
onChange={field.onChange}
placeholder={`${t('reserve.user')} ${t('common.contact')}${t(
'msg.placeholder',
)}`}
/>
</DLWrapper>
)}
defaultValue=""
rules={{ required: true }}
/>
<dl>
<dt>{t('common.attachment')}</dt>
<dd>
<Upload ref={uploadRef} multi />
</dd>
</dl>
</div>
<BottomButtons handleButtons={bottomButtons} />
</FormProvider>
<Backdrop className={classes.backdrop} open={loading}>
<CircularProgress color="inherit" />
</Backdrop>
{reserve && isEvent && (
<ReserveEventSource
data={reserve}
successCallback={successCallback}
errorCallback={errorCallback}
/>
)}
</>
)
}
export { ReserveEdit }

View File

@@ -0,0 +1,58 @@
import { SERVER_API_URL } from '@constants/env'
import { IReserve, reserveService } from '@service'
import React, { useEffect, useState } from 'react'
interface ReserveEventSourceProps {
data: IReserve
successCallback: () => void
errorCallback: (error: any, attachmentCode: string) => void
}
const ReserveEventSource = ({
data,
successCallback,
errorCallback,
}: ReserveEventSourceProps) => {
const [isSuccess, setSuccess] = useState<string>(null)
useEffect(() => {
let eventSource: EventSource = null
if (data) {
eventSource = new EventSource(
`${SERVER_API_URL}${reserveService.requestApiUrl}/direct/${data.reserveId}`,
)
eventSource.onmessage = event => {
if (event.data !== 'no news is good news') {
setSuccess(event.data)
eventSource.close()
}
}
eventSource.onerror = err => {
console.error('EventSource failed:', err)
eventSource.close()
}
}
return () => {
if (eventSource) {
eventSource.close()
eventSource = null
}
}
}, [data])
useEffect(() => {
if (isSuccess) {
if (isSuccess === 'true') {
successCallback()
} else {
errorCallback({ message: '예약에 실패했습니다.' }, data.attachmentCode)
}
}
}, [isSuccess])
return <>{isSuccess}</>
}
export default ReserveEventSource

View File

@@ -0,0 +1,82 @@
import AttachList from '@components/AttachList'
import { convertStringToDateFormat } from '@libs/date'
import { fileService, IAttachmentResponse, IReserve } from '@service'
import { errorStateSelector } from '@stores'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSetRecoilState } from 'recoil'
interface ReserveInfoProps {
data: IReserve
}
const ReserveInfo = ({ data }: ReserveInfoProps) => {
const { t } = useTranslation()
const setErrorState = useSetRecoilState(errorStateSelector)
// 첨부파일
const [attachList, setAttachList] = useState<IAttachmentResponse[]>(undefined)
useEffect(() => {
if (data.attachmentCode) {
const getAttachments = async () => {
try {
const result = await fileService.getAttachmentList(
data.attachmentCode,
)
if (result?.data) {
setAttachList(result.data)
}
} catch (error) {
setErrorState({ error })
}
}
getAttachments()
}
return () => setAttachList(null)
}, [data, setErrorState])
return (
<>
<h4>{`${t('reserve')} ${t('common.information')}`}</h4>
{data && (
<div className="view">
{data.reserveItem.categoryId !== 'education' ? (
<dl>
<dt>{`${t('reserve.request')} ${t('reserve.period')}`}</dt>
<dd>{`${convertStringToDateFormat(
data.reserveStartDate,
)}~${convertStringToDateFormat(data.reserveEndDate)}`}</dd>
</dl>
) : null}
{data.reserveItem.categoryId !== 'space' ? (
<dl>
{data.reserveItem.categoryId === 'education' ? (
<dt>{`${t('reserve.request')} ${t(
'reserve.number_of_people',
)}`}</dt>
) : (
<dt>{`${t('reserve.request')} ${t('reserve.count')}`}</dt>
)}
<dd>{data.reserveQty}</dd>
</dl>
) : null}
<dl>
<dt>{`${t('reserve.request')} ${t('reserve.purpose')}`}</dt>
<dd>{data.reservePurposeContent}</dd>
</dl>
<dl className="file">
<dt>{t('common.attachment')}</dt>
<dd>
<AttachList data={attachList} setData={setAttachList} readonly />
</dd>
</dl>
</div>
)}
</>
)
}
export { ReserveInfo }

View File

@@ -0,0 +1,53 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ReserveItemProps } from '.'
interface ReserveItemAdditionalPropps extends ReserveItemProps {}
const ReserveItemAdditional = ({ data }: ReserveItemAdditionalPropps) => {
const { t } = useTranslation()
return (
<>
<h4>{t('reserve_item.add_information')}</h4>
{data && (
<div className="view">
<dl>
<dt>{t('reserve_item.purpose')}</dt>
<dd>{data.purpose}</dd>
</dl>
<dl>
<dt>{t('reserve_item.target')}</dt>
<dd>{data.targetName}</dd>
</dl>
<dl>
<dt>{t('common.home_page_address')}</dt>
<dd>{data.homepage}</dd>
</dl>
<dl>
<dt>{t('reserve_item.contact')}</dt>
<dd>{data.contact}</dd>
</dl>
</div>
)}
<h4>{t('reserve_item.manager')}</h4>
{data && (
<div className="view">
<dl>
<dt>{t('reserve_item.dept')}</dt>
<dd>{data.managerDept}</dd>
</dl>
<dl>
<dt>{t('label.title.name')}</dt>
<dd>{data.managerName}</dd>
</dl>
<dl>
<dt>{t('common.contact')}</dt>
<dd>{data.managerContact}</dd>
</dl>
</div>
)}
</>
)
}
export { ReserveItemAdditional }

View File

@@ -0,0 +1,85 @@
import { convertStringToDateFormat } from '@libs/date'
import { ICode } from '@service'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ReserveItemProps } from '.'
interface ReserveItemInfoProps extends ReserveItemProps {
reserveStatus?: ICode
}
const ReserveItemInfo = ({ data, reserveStatus }: ReserveItemInfoProps) => {
const { t } = useTranslation()
return (
<>
<h4>{`${t('reserve_item')} ${t('common.information')}`}</h4>
{data && (
<div className="view">
<dl>
<dt>{t('location')}</dt>
<dd>{data.location.locationName}</dd>
</dl>
<dl>
<dt>{t('reserve_item.type')}</dt>
<dd>{data.categoryName}</dd>
</dl>
<dl>
<dt>{t('reserve_item.name')}</dt>
<dd>{data.reserveItemName}</dd>
</dl>
<dl>
<dt>{`${t('reserve.count')}/${t('reserve.number_of_people')}`}</dt>
<dd>{data.totalQty}</dd>
</dl>
<dl>
<dt>{t('reserve_item.selection_means')}</dt>
<dd>{data.selectionMeansName}</dd>
</dl>
<dl>
<dt>{`${t('reserve_item.operation')} ${t('reserve.period')}`}</dt>
<dd>{`${convertStringToDateFormat(
data.operationStartDate,
'yyyy-MM-dd',
)} ~ ${convertStringToDateFormat(
data.operationEndDate,
'yyyy-MM-dd',
)}`}</dd>
</dl>
<dl>
<dt>{`${t('reserve_item.request')} ${t('reserve.period')}`}</dt>
<dd>{`${convertStringToDateFormat(
data.requestStartDate,
'yyyy-MM-dd HH:mm',
)}
~ ${convertStringToDateFormat(
data.requestEndDate,
'yyyy-MM-dd HH:mm',
)}
`}</dd>
</dl>
<dl>
<dt>{`${t('common.free')}/${t('common.paid')}`}</dt>
<dd>{data.isPaid ? t('common.paid') : t('common.free')}</dd>
</dl>
{reserveStatus && (
<dl>
<dt>{`${t('reserve')}/${t('common.status')}`}</dt>
<dd
className={
reserveStatus.codeId === 'request' ||
reserveStatus.codeId === 'cancel'
? 'wait'
: ''
}
>
{reserveStatus.codeName}
</dd>
</dl>
)}
</div>
)}
</>
)
}
export { ReserveItemInfo }

View File

@@ -0,0 +1,11 @@
import { IReserveItem } from '@service'
export * from './ReserveComplete'
export * from './ReserveEdit'
export * from './ReserveInfo'
export * from './ReserveItemAdditional'
export * from './ReserveItemInfo'
export interface ReserveItemProps {
data: IReserveItem
}

View File

@@ -0,0 +1,103 @@
import { OptionsType, SelectBox, SelectType } from '@components/Inputs'
import useInputs from '@hooks/useInputs'
import { conditionAtom, conditionSelector, conditionValue } from '@stores'
import React, { createRef, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useRecoilValue, useSetRecoilState } from 'recoil'
interface SearchProps {
options?: OptionsType[]
buttonTitle?: string
handleSearch: () => void
conditionKey: string // 조회조건 상태값을 관리할 키 값 (e.g. 이용약관관리 -> policy)
customKeyword?: conditionValue
conditionNodes?: React.ReactNode
className?: string
}
const Search = (props: SearchProps) => {
const {
options,
buttonTitle,
handleSearch,
conditionKey,
customKeyword,
conditionNodes,
className,
} = props
const { t, i18n } = useTranslation()
const setValue = useSetRecoilState(conditionSelector(conditionKey))
const conditionState = useRecoilValue(conditionAtom(conditionKey))
const searchText = useInputs(conditionState?.keyword || '')
const conditionRef = createRef<SelectType>()
const defaultOptions = useMemo(() => {
return (
options || [
{
value: 'title',
label: t('posts.posts_title'),
},
{
value: 'content',
label: t('posts.posts_content'),
},
]
)
}, [options, i18n])
const search = useCallback(() => {
setValue({
keywordType: conditionRef.current?.selectedValue,
keyword: searchText.value,
...customKeyword,
})
handleSearch()
}, [conditionRef, searchText, customKeyword])
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
search()
}
const handleKeyPress = (event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
search()
}
}
const handleKeyUp = (event: React.KeyboardEvent<HTMLElement>) => {
/* setValue({
keywordType: conditionRef.current?.selectedValue,
keyword: searchText.value,
customKeyword,
}) */
}
return (
<>
{conditionNodes}
<SelectBox
ref={conditionRef}
options={defaultOptions}
className={className}
/>
<input
type="text"
title={`${t('common.search_word')}`}
placeholder={`${t('common.search_word')}${t('msg.placeholder')}`}
onKeyPress={handleKeyPress}
onKeyUp={handleKeyUp}
{...searchText}
/>
<button onClick={handleClick}>
{buttonTitle || t('label.button.find')}
</button>
</>
)
}
export default Search

View File

@@ -0,0 +1,137 @@
import ActiveLink from '@components/ActiveLink'
import Hidden from '@material-ui/core/Hidden'
import IconButton from '@material-ui/core/IconButton'
import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown'
import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp'
import { currentMenuStateAtom, ISideMenu, menuStateAtom } from '@stores'
import { translateToLang } from '@utils'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRecoilValue } from 'recoil'
const renderSitemap = (
item: ISideMenu,
locale: string = 'ko',
current?: ISideMenu,
) => {
if (item.children.length <= 0) {
return
}
const children = item.children.map(child => {
return React.createElement(
'li',
{
key: `sitemap-li-${child.id}`,
className: `${current?.id === child.id ? 'on' : ''}`,
},
<ActiveLink
href={child.urlPath}
children={translateToLang(locale, child)}
key={`sitemap-a-${child.id}`}
/>,
)
})
return React.createElement(
'ul',
{ key: `sitemap-sub-ul-${item.id}` },
children,
)
}
const Sitemap = () => {
const menus = useRecoilValue(menuStateAtom)
const currentMenu = useRecoilValue(currentMenuStateAtom)
const { i18n } = useTranslation()
const [collapseItem, setCollapseItem] = useState<ValueType>(
currentMenu ? currentMenu.parentId : menus[0].id,
)
const isActive = useCallback(
(id: ValueType): boolean => {
if (collapseItem) {
if (collapseItem === id) {
return true
}
return false
}
return false
},
[currentMenu, collapseItem],
)
const handleChevronClick = (
e: React.MouseEvent<HTMLButtonElement>,
item: ISideMenu,
isActive: boolean,
) => {
e.preventDefault()
if (isActive) {
setCollapseItem(undefined)
} else {
setCollapseItem(item.id)
}
}
return (
<>
<nav>
<div>
{menus &&
menus
.filter(item => item.isShow)
.map((item, index) => {
return (
<ul key={`sitemap-ul-${item.id}`}>
<li
key={`sitemap-li-${item.id}`}
className={`${isActive(item.id) ? 'on' : ''}`}
>
<Hidden smUp>
<div>
<ActiveLink
href={
item.children.length > 0
? item.children[0].urlPath
: item.urlPath
}
children={translateToLang(i18n.language, item)}
/>
<IconButton
color="inherit"
onClick={e =>
handleChevronClick(e, item, isActive(item.id))
}
>
{isActive(item.id) ? (
<KeyboardArrowUpIcon />
) : (
<KeyboardArrowDownIcon />
)}
</IconButton>
</div>
{collapseItem === item.id
? renderSitemap(item, i18n.language, currentMenu)
: null}
</Hidden>
<Hidden xsDown>
{renderSitemap(item, i18n.language, currentMenu)}
</Hidden>
</li>
</ul>
)
})}
</div>
</nav>
</>
)
}
export default Sitemap

View File

@@ -0,0 +1,132 @@
import React from 'react'
import Collapse from '@material-ui/core/Collapse'
import IconButton from '@material-ui/core/IconButton'
import TableCell from '@material-ui/core/TableCell'
import TableRow from '@material-ui/core/TableRow'
import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown'
import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp'
import {
GridRowData,
GridRowId,
GridValueFormatterParams,
} from '@material-ui/data-grid'
import { CollapseColDef } from './CollapsibleTable'
interface CollapseRowProps {
columns: CollapseColDef[]
row: GridRowData
collapseColumn: React.ReactNode
collapseClassName?: string
rowId?: GridRowId
}
const formatterParams = (
column: CollapseColDef,
row: GridRowData,
rowId: GridRowId,
): GridValueFormatterParams => {
return {
id: row[rowId],
field: column.field,
value: row[column.field],
row: row,
colDef: { ...column, computedWidth: column.width },
api: null,
cellMode: column.editable ? 'edit' : 'view',
hasFocus: false,
tabIndex: -1,
getValue: (id: GridRowId, field: string) => row[field],
}
}
const cellParams = (
formatParams: GridValueFormatterParams,
column: CollapseColDef,
row: GridRowData,
) => {
let formattedValue = formatParams.value
if (column.valueFormatter) {
formattedValue = column.valueFormatter(formatParams)
}
return {
...formatParams,
formattedValue,
}
}
const renderCell = (
column: CollapseColDef,
row: GridRowData,
rowId: GridRowId,
rowIdx: number,
toggleOpen: () => void,
) => {
let cell: React.ReactNode = <>{row[column.field]}</>
const gridValueFormatterParams = formatterParams(column, row, rowId)
if (column.valueFormatter) {
cell = column.valueFormatter(gridValueFormatterParams)
} else if (column.renderCell) {
cell = column.renderCell(cellParams(gridValueFormatterParams, column, row))
}
return (
<TableCell
key={`collapse-cell-${column.field}-${rowIdx}`}
align={column.align}
width={column.width}
className={String(column.cellClassName) || ''}
onClick={toggleOpen}
>
{cell}
</TableCell>
)
}
const CollapseRow = (props: CollapseRowProps) => {
const {
columns,
row,
collapseColumn,
collapseClassName = 'content',
rowId = 'id',
} = props
const [open, setOpen] = React.useState(false)
const toggleOpen = () => {
setOpen(!open)
}
return (
<>
<TableRow style={{ height: 70 }}>
{columns.map((col, index) => {
return renderCell(col, row, rowId, index, toggleOpen)
})}
<TableCell align="center">
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
</TableRow>
<TableRow>
<TableCell
style={{ padding: 0, width: '50px' }}
colSpan={columns.length + 1}
>
<Collapse in={open} timeout="auto" unmountOnExit>
<div className={collapseClassName}>{collapseColumn}</div>
</Collapse>
</TableCell>
</TableRow>
</>
)
}
export default CollapseRow

View File

@@ -0,0 +1,120 @@
import Paper from '@material-ui/core/Paper'
import Table from '@material-ui/core/Table'
import TableBody from '@material-ui/core/TableBody'
import TableCell from '@material-ui/core/TableCell'
import TableContainer from '@material-ui/core/TableContainer'
import TableHead from '@material-ui/core/TableHead'
import TableRow from '@material-ui/core/TableRow'
import withWidth, { WithWidthProps } from '@material-ui/core/withWidth'
import {
GridColDef,
GridRowData,
GridRowId,
GridRowsProp,
} from '@material-ui/data-grid'
import React from 'react'
import CollapseRow from './CollapseRow'
import CustomPagination from './CustomPagination'
export interface CollapseColDef
extends Omit<
GridColDef,
| 'sortComparator'
| 'valueGetter'
| 'valueParser'
| 'renderEditCell'
| 'renderHeader'
> {}
interface CollapsibleTableProps extends WithWidthProps {
hideColumns?: boolean
columns: CollapseColDef[]
xsColumns: CollapseColDef[]
rows: GridRowsProp
rowId?: GridRowId
renderCollapseRow: (row: GridRowData) => React.ReactNode
page: number
first: boolean
last: boolean
totalPages: number
handleChangePage: (
event: React.MouseEvent<HTMLButtonElement> | null,
page: number,
) => void
}
const CollapsibleTable = (props: CollapsibleTableProps) => {
const {
width,
hideColumns = false,
columns,
xsColumns,
rows,
rowId = 'id',
renderCollapseRow,
page,
first,
last,
totalPages,
handleChangePage,
} = props
return (
<TableContainer className="collapsible" component={Paper}>
{rows.length > 0 ? (
<Table aria-label="collapsible table">
{!hideColumns && (
<TableHead>
<TableRow>
{width === 'xs'
? xsColumns.map(item => (
<TableCell
key={`collapse-header-row-${item.field}`}
align={item.headerAlign}
className={String(item.headerClassName) || ''}
>
{item.headerName}
</TableCell>
))
: columns.map(item => (
<TableCell
key={`collapse-header-row-${item.field}`}
align={item.headerAlign}
className={String(item.headerClassName) || ''}
>
{item.headerName}
</TableCell>
))}
<TableCell />
</TableRow>
</TableHead>
)}
<TableBody>
{rows.map(row => (
<CollapseRow
key={`collapse-body-row-${row[rowId]}`}
columns={width === 'xs' ? xsColumns : columns}
row={row}
rowId={rowId}
collapseClassName="content"
collapseColumn={renderCollapseRow(row)}
/>
))}
</TableBody>
</Table>
) : (
<div className="no-rows">No rows</div>
)}
<CustomPagination
page={page}
first={first}
last={last}
onChangePage={handleChangePage}
totalPages={totalPages}
/>
</TableContainer>
)
}
export default withWidth()(CollapsibleTable)

View File

@@ -0,0 +1,107 @@
import Button from '@material-ui/core/Button'
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'
import React from 'react'
interface CustomPaginationProps {
page: number
totalPages: number
first: boolean
last: boolean
onChangePage: (
event: React.MouseEvent<HTMLButtonElement>,
newPage: number,
) => void
}
export default function CustomPagination(props: CustomPaginationProps) {
const { page, totalPages, first, last, 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, totalPages - 1)
}
const handlePageButtonClick = (
event: React.MouseEvent<HTMLButtonElement>,
page: number,
) => {
onChangePage(event, page)
}
return (
<div className="paging">
<nav className="MuiPagination-root">
<div className="MuiPagination-ul">
<IconButton
className="MuiPaginationItem-root MuiPaginationItem-page"
onClick={handleFirstPageButtonClick}
disabled={first}
aria-label="first page"
>
<FirstPageIcon />
</IconButton>
<IconButton
className="MuiPaginationItem-root MuiPaginationItem-page"
onClick={handleBackButtonClick}
disabled={first}
aria-label="previous page"
>
<KeyboardArrowLeft />
</IconButton>
{totalPages > 0
? [...Array(totalPages).keys()].map(item => (
<Button
className={`MuiPaginationItem-root MuiPaginationItem-page ${
page === item ? 'Mui-selected' : ''
}`}
key={`pagin-item-${item}`}
onClick={e => {
handlePageButtonClick(e, item)
}}
>
{item + 1}
</Button>
))
: null}
<IconButton
className="MuiPaginationItem-root MuiPaginationItem-page"
onClick={handleNextButtonClick}
disabled={last}
aria-label="next page"
>
<KeyboardArrowRight />
</IconButton>
<IconButton
className="MuiPaginationItem-root MuiPaginationItem-page"
onClick={handleLastPageButtonClick}
disabled={last}
aria-label="last page"
>
<LastPageIcon />
</IconButton>
</div>
</nav>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import React from 'react'
import Pagination, { PaginationProps } from '@material-ui/lab/Pagination'
import { useGridSlotComponentProps } from '@material-ui/data-grid'
import PaginationItem from '@material-ui/lab/PaginationItem'
interface DataGridPaginationProps extends PaginationProps {}
const DataGridPagination = (props: DataGridPaginationProps) => {
const { state, apiRef } = useGridSlotComponentProps()
return (
<div className="paging">
<Pagination
color="primary"
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}
/>
</div>
)
}
export default DataGridPagination

View File

@@ -0,0 +1,42 @@
import React from 'react'
import {
DataGrid,
DataGridProps,
GridColDef,
} from '@material-ui/data-grid'
import withWidth, {
WithWidthProps,
} from '@material-ui/core/withWidth'
import { DEFUALT_GRID_PAGE_SIZE } from '@constants'
import DataGridPagination from './DataGridPagination'
interface DataGridTableProps extends WithWidthProps, DataGridProps {
xsColumns: GridColDef[]
}
const DataGridTable = (props: DataGridTableProps) => {
const { columns, rows, xsColumns, width, getRowId, pageSize, ...rest } = props
return (
<div className="list">
<DataGrid
rows={rows || []}
columns={width === 'xs' ? xsColumns : columns}
pageSize={pageSize || DEFUALT_GRID_PAGE_SIZE}
disableSelectionOnClick
disableColumnFilter
disableColumnMenu
disableDensitySelector
headerHeight={width === 'xs' ? 0 : 70}
rowHeight={width === 'xs' ? 80 : 70}
autoHeight
pagination
components={{ Pagination: DataGridPagination }}
getRowId={getRowId || (r => r.id)}
{...rest}
/>
</div>
)
}
export default withWidth()(DataGridTable)

View File

@@ -0,0 +1,3 @@
export * from './DataGridTable'
export * from './DataGridPagination'
export * from './CollapsibleTable'

View File

@@ -0,0 +1,117 @@
import Avatar from '@material-ui/core/Avatar'
import Grid from '@material-ui/core/Grid'
import IconButton from '@material-ui/core/IconButton'
import List from '@material-ui/core/List'
import ListItem from '@material-ui/core/ListItem'
import ListItemAvatar from '@material-ui/core/ListItemAvatar'
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
import ListItemText from '@material-ui/core/ListItemText'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import DeleteIcon from '@material-ui/icons/Delete'
import FolderIcon from '@material-ui/icons/Folder'
import { IFile } from '@service'
import { formatBytes } from '@utils'
import produce from 'immer'
import React, { useContext, useEffect, useState } from 'react'
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)}
className={classes.pd0}
/>
<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,63 @@
import { DEFAULT_ACCEPT_FILE_EXT } from '@constants'
import Hidden from '@material-ui/core/Hidden'
import { IFile } from '@service'
import React, { useContext, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { FileContext, UploadProps } from '.'
const FileUpload = (props: UploadProps) => {
const { accept, multi } = props
const { t } = useTranslation()
const fileInputRef = useRef<HTMLInputElement>(null)
const { selectedFiles, setSelectedFilesHandler } = useContext(FileContext)
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)
}
const handleInputClick = (e: React.MouseEvent<HTMLInputElement>) => {
e.preventDefault()
fileInputRef.current?.click()
}
return (
<div className="file custom">
<input
ref={fileInputRef}
accept={accept || DEFAULT_ACCEPT_FILE_EXT}
onChange={handleChangeFiles}
multiple={multi}
type="file"
id="file"
placeholder={t('file.placeholder')}
/>
<input
type="text"
placeholder={t('file.placeholder')}
readOnly
onClick={handleInputClick}
/>
<Hidden xsDown>
<label htmlFor="file">{t('file.search')}</label>
</Hidden>
</div>
)
}
export default FileUpload

View File

@@ -0,0 +1,235 @@
import CustomAlert from '@components/CustomAlert'
import { DEFAULT_ACCEPT_FILE_EXT_TEXT } from '@constants'
import {
AttachmentSavePayload,
fileService,
IAttachmentResponse,
IFile,
UploadInfoReqeust,
} from '@service'
import { format, formatBytes } from '@utils'
import axios from 'axios'
import { useTranslation } from 'next-i18next'
import React, {
createContext,
forwardRef,
useImperativeHandle,
useState,
} from 'react'
import FileList from './FileList'
import FileUpload from './FileUpload'
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
acceptText?: 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,
acceptText = DEFAULT_ACCEPT_FILE_EXT_TEXT,
} = props
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,
})
}
})
}
const baseUrl = axios.defaults.baseURL
axios.defaults.baseURL = ''
const result = fileService
.upload({
fileList: selectedFiles,
attachmentCode: attachmentCode,
info,
list: saveList,
})
.then(response => {
axios.defaults.baseURL = baseUrl
setSelectedFiles(undefined)
resolve(response.data)
})
.catch(error => {
axios.defaults.baseURL = baseUrl
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
}
// const baseUrl = axios.defaults.baseURL
// axios.defaults.baseURL = ''
fileService
.save({
attachmentCode: attachmentCode,
info,
list: saveList,
})
.then(response => {
// axios.defaults.baseURL = baseUrl
resolve(response.data)
})
.catch(error => {
// axios.defaults.baseURL = baseUrl
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 (
<>
<FileContext.Provider value={{ selectedFiles, setSelectedFilesHandler }}>
<FileUpload {...props} />
<FileList />
</FileContext.Provider>
<div className="custom">
* {t('file.accept_ext')} : {acceptText}
<br />
{uploadLimitSize &&
`* ${format(t('file.msg_limit.format'), [
formatBytes(uploadLimitSize, 0),
])}`}
</div>
<CustomAlert handleAlert={handleAlert} {...customAlert} />
</>
)
})
export default Upload

View File

@@ -0,0 +1,33 @@
import { BottomButtons, IButtons } from '@components/Buttons'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
interface UserInfoDoneProps {
handleList: () => void
}
const UserInfoDone = ({ handleList }: UserInfoDoneProps) => {
const { t } = useTranslation()
const bottomButtons = useMemo(
(): IButtons[] => [
{
id: 'board-edit-list',
title: t('label.button.first'),
href: ``,
handleClick: handleList,
},
],
[t],
)
return (
<article className="mypage">
<div className="message">
<span className="change">{t('label.text.user.info.modified')}</span>
</div>
<BottomButtons handleButtons={bottomButtons} />
</article>
)
}
export { UserInfoDone }

View File

@@ -0,0 +1,169 @@
import ActiveLink from '@components/ActiveLink'
import { BottomButtons, IButtons } from '@components/Buttons'
import { DLWrapper } from '@components/WriteDLFields'
import { userService } from '@service'
import { errorStateSelector, userAtom } from '@stores'
import { format } from '@utils'
import React, { useMemo } from 'react'
import { Controller, UseFormGetValues, UseFormSetFocus } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { useRecoilValue, useSetRecoilState } from 'recoil'
import { IUserForm, UserInfoProps } from '.'
interface UserInfoModifiedPrpps extends UserInfoProps {
handleUpdate: () => void
getValues: UseFormGetValues<IUserForm>
setFocus: UseFormSetFocus<IUserForm>
showMessage: (message: string, callback?: () => void) => void
setCheckedEmail: React.Dispatch<React.SetStateAction<boolean>>
}
const UserInfoModified = (props: UserInfoModifiedPrpps) => {
const {
control,
formState,
handleUpdate,
getValues,
setFocus,
showMessage,
setCheckedEmail,
} = props
const { t } = useTranslation()
const user = useRecoilValue(userAtom)
const setErrorState = useSetRecoilState(errorStateSelector)
// 이메일 중복확인
const handleCheckEmail = async () => {
const emailValue = getValues('email')
if (user.email === emailValue) {
showMessage(t('msg.notmodified'))
return
}
if (
/^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i.test(
emailValue,
) === false
) {
showMessage(t('valid.email.pattern'), () => setFocus('email'))
return
}
try {
const result = await userService.existsEmail(emailValue, user.userId)
if (result === true) {
showMessage(t('msg.user.email.exists'), () => {
setFocus('email')
})
} else {
showMessage(t('msg.user.email.notexists'))
setCheckedEmail(true)
}
} catch (error) {
setErrorState({ error })
}
}
const bottomButtons = useMemo(
(): IButtons[] => [
{
id: 'board-edit-save',
title: t('label.button.confirm'),
href: '',
className: 'blue',
handleClick: handleUpdate,
},
{
id: 'board-edit-list',
title: t('label.button.cancel'),
href: `/`,
},
],
[props, t],
)
return (
<div className="table_write01">
<span>{t('common.required_fields')}</span>
<div className="write">
<Controller
control={control}
name="email"
render={({ field, fieldState }) => (
<DLWrapper
title={t('user.email')}
className="inputTitle"
required
error={fieldState.error}
>
<input
autoFocus
ref={field.ref}
type="text"
value={field.value}
onChange={field.onChange}
placeholder={t('user.email')}
inputMode="email"
maxLength={50}
/>
<ActiveLink href="" handleActiveLinkClick={handleCheckEmail}>
{t('label.button.check_email')}
</ActiveLink>
</DLWrapper>
)}
defaultValue=""
rules={{
required: true,
maxLength: {
value: 50,
message: format(t('valid.maxlength.format'), [50]),
},
pattern: {
value:
/^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i,
message: t('valid.email.pattern'),
},
}}
/>
<Controller
control={control}
name="userName"
render={({ field, fieldState }) => (
<DLWrapper
title={t('label.title.name')}
className="inputTitle"
required
error={fieldState.error}
>
<input
type="text"
ref={field.ref}
value={field.value}
onChange={field.onChange}
placeholder={t('label.title.name')}
/>
</DLWrapper>
)}
defaultValue=""
rules={{
required: true,
minLength: {
value: 2,
message: format(t('valid.minlength.format'), [2]),
},
maxLength: {
value: 25,
message: format(t('valid.maxlength.format'), [25]),
},
}}
/>
</div>
<BottomButtons handleButtons={bottomButtons} />
</div>
)
}
export { UserInfoModified }

View File

@@ -0,0 +1,16 @@
import { Control, FormState } from 'react-hook-form'
export * from './UserInfoDone'
export * from './UserInfoModified'
export interface UserInfoProps {
control: Control<any>
formState: FormState<any>
handleList: () => void
}
export interface IUserForm {
currentPassword?: string
password: string
email: string
userName: string
}

View File

@@ -0,0 +1,94 @@
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,89 @@
import CustomAlert from '@components/CustomAlert'
import { ButtonProps } from '@material-ui/core/Button'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import { errorStateAtom } from '@stores'
import { useSnackbar } from 'notistack'
import React, { useEffect, useState } from 'react'
import { useRecoilState } from 'recoil'
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 classes = useStyles()
const [errorState, setErrorState] = useRecoilState(errorStateAtom)
const { enqueueSnackbar } = useSnackbar()
const [alertState, setAlertState] = useState<{
open: boolean
errors: string[]
}>({
open: false,
errors: [],
})
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 Loader from '@components/Loader'
import React from 'react'
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,27 @@
import ValidationAlert from '@components/ValidationAlert'
import React from 'react'
import { FieldError } from 'react-hook-form'
interface DLWrapperProps {
title: string
error?: FieldError
className?: string
required?: boolean
children: React.ReactNode
}
const DLWrapper = (props: DLWrapperProps) => {
const { title, error, className, required, children } = props
return (
<dl>
<dt className={required ? 'import' : ''}>{title}</dt>
<dd className={className}>
{children}
{error && <ValidationAlert fieldError={error} label={title} />}
</dd>
</dl>
)
}
export { DLWrapper }

View File

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