✨ frontend add
This commit is contained in:
39
frontend/portal/src/components/ActiveLink/index.tsx
Normal file
39
frontend/portal/src/components/ActiveLink/index.tsx
Normal 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
|
||||
21
frontend/portal/src/components/App/GlobalStyles.tsx
Normal file
21
frontend/portal/src/components/App/GlobalStyles.tsx
Normal 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
|
||||
163
frontend/portal/src/components/App/index.tsx
Normal file
163
frontend/portal/src/components/App/index.tsx
Normal 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
|
||||
69
frontend/portal/src/components/AttachList/index.tsx
Normal file
69
frontend/portal/src/components/AttachList/index.tsx
Normal 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
|
||||
127
frontend/portal/src/components/Auth/LoginForm.tsx
Normal file
127
frontend/portal/src/components/Auth/LoginForm.tsx
Normal 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 }
|
||||
1
frontend/portal/src/components/Auth/index.ts
Normal file
1
frontend/portal/src/components/Auth/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './LoginForm'
|
||||
126
frontend/portal/src/components/BoardList/FAQBoardList.tsx
Normal file
126
frontend/portal/src/components/BoardList/FAQBoardList.tsx
Normal 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 }
|
||||
229
frontend/portal/src/components/BoardList/NormalBoardList.tsx
Normal file
229
frontend/portal/src/components/BoardList/NormalBoardList.tsx
Normal 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 }
|
||||
145
frontend/portal/src/components/BoardList/QnABoardList.tsx
Normal file
145
frontend/portal/src/components/BoardList/QnABoardList.tsx
Normal 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 }
|
||||
13
frontend/portal/src/components/BoardList/index.ts
Normal file
13
frontend/portal/src/components/BoardList/index.ts
Normal 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[]
|
||||
34
frontend/portal/src/components/Buttons/BottomButtons.tsx
Normal file
34
frontend/portal/src/components/Buttons/BottomButtons.tsx
Normal 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 }
|
||||
78
frontend/portal/src/components/Buttons/GoogleLoginButton.tsx
Normal file
78
frontend/portal/src/components/Buttons/GoogleLoginButton.tsx
Normal 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 }
|
||||
73
frontend/portal/src/components/Buttons/KakaoLoginButton.tsx
Normal file
73
frontend/portal/src/components/Buttons/KakaoLoginButton.tsx
Normal 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 }
|
||||
182
frontend/portal/src/components/Buttons/NaverLoginButton.tsx
Normal file
182
frontend/portal/src/components/Buttons/NaverLoginButton.tsx
Normal 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 }
|
||||
5
frontend/portal/src/components/Buttons/index.ts
Normal file
5
frontend/portal/src/components/Buttons/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './BottomButtons'
|
||||
export * from './KakaoLoginButton'
|
||||
export * from './NaverLoginButton'
|
||||
// export * from './NaverLoginButton2'
|
||||
export * from './GoogleLoginButton'
|
||||
62
frontend/portal/src/components/Comments/AddComments.tsx
Normal file
62
frontend/portal/src/components/Comments/AddComments.tsx
Normal 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 }
|
||||
45
frontend/portal/src/components/Comments/CommentsList.tsx
Normal file
45
frontend/portal/src/components/Comments/CommentsList.tsx
Normal 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 }
|
||||
79
frontend/portal/src/components/Comments/EditComments.tsx
Normal file
79
frontend/portal/src/components/Comments/EditComments.tsx
Normal 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 }
|
||||
44
frontend/portal/src/components/Comments/ViewComments.tsx
Normal file
44
frontend/portal/src/components/Comments/ViewComments.tsx
Normal 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 }
|
||||
2
frontend/portal/src/components/Comments/index.ts
Normal file
2
frontend/portal/src/components/Comments/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './EditComments'
|
||||
export * from './CommentsList'
|
||||
110
frontend/portal/src/components/CustomAlert/index.tsx
Normal file
110
frontend/portal/src/components/CustomAlert/index.tsx
Normal 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
|
||||
121
frontend/portal/src/components/CustomConfirm/index.tsx
Normal file
121
frontend/portal/src/components/CustomConfirm/index.tsx
Normal 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
|
||||
20
frontend/portal/src/components/CustomSwiper/index.tsx
Normal file
20
frontend/portal/src/components/CustomSwiper/index.tsx
Normal 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
|
||||
227
frontend/portal/src/components/EditForm/NormalEditForm.tsx
Normal file
227
frontend/portal/src/components/EditForm/NormalEditForm.tsx
Normal 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 }
|
||||
177
frontend/portal/src/components/EditForm/QnAEditForm.tsx
Normal file
177
frontend/portal/src/components/EditForm/QnAEditForm.tsx
Normal 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 }
|
||||
9
frontend/portal/src/components/EditForm/index.ts
Normal file
9
frontend/portal/src/components/EditForm/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IPostsForm } from '@service'
|
||||
|
||||
export * from './NormalEditForm'
|
||||
export * from './QnAEditForm'
|
||||
|
||||
export interface EditFormProps {
|
||||
// handleSave: () => void
|
||||
post: IPostsForm
|
||||
}
|
||||
63
frontend/portal/src/components/Editor/index.tsx
Normal file
63
frontend/portal/src/components/Editor/index.tsx
Normal 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
|
||||
38
frontend/portal/src/components/Errors/ErrorPage.tsx
Normal file
38
frontend/portal/src/components/Errors/ErrorPage.tsx
Normal 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 }
|
||||
38
frontend/portal/src/components/Errors/ErrorPopup.tsx
Normal file
38
frontend/portal/src/components/Errors/ErrorPopup.tsx
Normal 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 }
|
||||
73
frontend/portal/src/components/Errors/index.tsx
Normal file
73
frontend/portal/src/components/Errors/index.tsx
Normal 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
|
||||
64
frontend/portal/src/components/Inputs/SelectBox.tsx
Normal file
64
frontend/portal/src/components/Inputs/SelectBox.tsx
Normal 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 }
|
||||
1
frontend/portal/src/components/Inputs/index.ts
Normal file
1
frontend/portal/src/components/Inputs/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './SelectBox'
|
||||
67
frontend/portal/src/components/Layout/Body.tsx
Normal file
67
frontend/portal/src/components/Layout/Body.tsx
Normal 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
|
||||
68
frontend/portal/src/components/Layout/Breadcrumb.tsx
Normal file
68
frontend/portal/src/components/Layout/Breadcrumb.tsx
Normal 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
|
||||
109
frontend/portal/src/components/Layout/Footer.tsx
Normal file
109
frontend/portal/src/components/Layout/Footer.tsx
Normal 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) 표준프레임워크 포털 All Rights Reserved.
|
||||
</p>
|
||||
</Hidden>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
||||
109
frontend/portal/src/components/Layout/Header.tsx
Normal file
109
frontend/portal/src/components/Layout/Header.tsx
Normal 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
|
||||
22
frontend/portal/src/components/Layout/NoLeftBody.tsx
Normal file
22
frontend/portal/src/components/Layout/NoLeftBody.tsx
Normal 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
|
||||
37
frontend/portal/src/components/Layout/SideBar.tsx
Normal file
37
frontend/portal/src/components/Layout/SideBar.tsx
Normal 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
|
||||
23
frontend/portal/src/components/Layout/index.tsx
Normal file
23
frontend/portal/src/components/Layout/index.tsx
Normal 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
|
||||
26
frontend/portal/src/components/Loader/index.tsx
Normal file
26
frontend/portal/src/components/Loader/index.tsx
Normal 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
|
||||
431
frontend/portal/src/components/Main/MainLG.tsx
Normal file
431
frontend/portal/src/components/Main/MainLG.tsx
Normal 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>가이드&다운로드</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 }
|
||||
301
frontend/portal/src/components/Main/MainSM.tsx
Normal file
301
frontend/portal/src/components/Main/MainSM.tsx
Normal 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>가이드&다운로드</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 }
|
||||
15
frontend/portal/src/components/Main/index.tsx
Normal file
15
frontend/portal/src/components/Main/index.tsx
Normal 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]
|
||||
144
frontend/portal/src/components/Password/PasswordChange.tsx
Normal file
144
frontend/portal/src/components/Password/PasswordChange.tsx
Normal 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 }
|
||||
86
frontend/portal/src/components/Password/PasswordConfirm.tsx
Normal file
86
frontend/portal/src/components/Password/PasswordConfirm.tsx
Normal 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 }
|
||||
33
frontend/portal/src/components/Password/PasswordDone.tsx
Normal file
33
frontend/portal/src/components/Password/PasswordDone.tsx
Normal 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 }
|
||||
17
frontend/portal/src/components/Password/index.tsx
Normal file
17
frontend/portal/src/components/Password/index.tsx
Normal 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
|
||||
}
|
||||
42
frontend/portal/src/components/Reserve/ReserveComplete.tsx
Normal file
42
frontend/portal/src/components/Reserve/ReserveComplete.tsx
Normal 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 }
|
||||
@@ -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 }
|
||||
384
frontend/portal/src/components/Reserve/ReserveEdit.tsx
Normal file
384
frontend/portal/src/components/Reserve/ReserveEdit.tsx
Normal 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 }
|
||||
@@ -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
|
||||
82
frontend/portal/src/components/Reserve/ReserveInfo.tsx
Normal file
82
frontend/portal/src/components/Reserve/ReserveInfo.tsx
Normal 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 }
|
||||
@@ -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 }
|
||||
85
frontend/portal/src/components/Reserve/ReserveItemInfo.tsx
Normal file
85
frontend/portal/src/components/Reserve/ReserveItemInfo.tsx
Normal 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 }
|
||||
11
frontend/portal/src/components/Reserve/index.tsx
Normal file
11
frontend/portal/src/components/Reserve/index.tsx
Normal 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
|
||||
}
|
||||
103
frontend/portal/src/components/Search/index.tsx
Normal file
103
frontend/portal/src/components/Search/index.tsx
Normal 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
|
||||
137
frontend/portal/src/components/Sitemap/index.tsx
Normal file
137
frontend/portal/src/components/Sitemap/index.tsx
Normal 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
|
||||
132
frontend/portal/src/components/TableList/CollapseRow.tsx
Normal file
132
frontend/portal/src/components/TableList/CollapseRow.tsx
Normal 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
|
||||
120
frontend/portal/src/components/TableList/CollapsibleTable.tsx
Normal file
120
frontend/portal/src/components/TableList/CollapsibleTable.tsx
Normal 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)
|
||||
107
frontend/portal/src/components/TableList/CustomPagination.tsx
Normal file
107
frontend/portal/src/components/TableList/CustomPagination.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
42
frontend/portal/src/components/TableList/DataGridTable.tsx
Normal file
42
frontend/portal/src/components/TableList/DataGridTable.tsx
Normal 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)
|
||||
3
frontend/portal/src/components/TableList/index.ts
Normal file
3
frontend/portal/src/components/TableList/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './DataGridTable'
|
||||
export * from './DataGridPagination'
|
||||
export * from './CollapsibleTable'
|
||||
117
frontend/portal/src/components/Upload/FileList.tsx
Normal file
117
frontend/portal/src/components/Upload/FileList.tsx
Normal 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
|
||||
63
frontend/portal/src/components/Upload/FileUpload.tsx
Normal file
63
frontend/portal/src/components/Upload/FileUpload.tsx
Normal 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
|
||||
235
frontend/portal/src/components/Upload/index.tsx
Normal file
235
frontend/portal/src/components/Upload/index.tsx
Normal 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
|
||||
33
frontend/portal/src/components/UserInfo/UserInfoDone.tsx
Normal file
33
frontend/portal/src/components/UserInfo/UserInfoDone.tsx
Normal 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 }
|
||||
169
frontend/portal/src/components/UserInfo/UserInfoModified.tsx
Normal file
169
frontend/portal/src/components/UserInfo/UserInfoModified.tsx
Normal 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 }
|
||||
16
frontend/portal/src/components/UserInfo/index.tsx
Normal file
16
frontend/portal/src/components/UserInfo/index.tsx
Normal 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
|
||||
}
|
||||
94
frontend/portal/src/components/ValidationAlert/index.tsx
Normal file
94
frontend/portal/src/components/ValidationAlert/index.tsx
Normal 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
|
||||
89
frontend/portal/src/components/Wrapper/GlobalError.tsx
Normal file
89
frontend/portal/src/components/Wrapper/GlobalError.tsx
Normal 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
|
||||
14
frontend/portal/src/components/Wrapper/SSRSafeSuspense.tsx
Normal file
14
frontend/portal/src/components/Wrapper/SSRSafeSuspense.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import useMounted from '@hooks/useMounted'
|
||||
import React, { Suspense, SuspenseProps } from 'react'
|
||||
|
||||
const SSRSafeSuspense = (props: SuspenseProps) => {
|
||||
const isMounted = useMounted()
|
||||
|
||||
if (isMounted) {
|
||||
return <Suspense {...props} />
|
||||
}
|
||||
|
||||
return <>{props.fallback}</>
|
||||
}
|
||||
|
||||
export default SSRSafeSuspense
|
||||
19
frontend/portal/src/components/Wrapper/index.tsx
Normal file
19
frontend/portal/src/components/Wrapper/index.tsx
Normal 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
|
||||
27
frontend/portal/src/components/WriteDLFields/DLWrapper.tsx
Normal file
27
frontend/portal/src/components/WriteDLFields/DLWrapper.tsx
Normal 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 }
|
||||
1
frontend/portal/src/components/WriteDLFields/index.ts
Normal file
1
frontend/portal/src/components/WriteDLFields/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './DLWrapper'
|
||||
Reference in New Issue
Block a user