diff --git a/public/assets/favicon.svg b/public/assets/favicon.svg new file mode 100644 index 00000000..38f1e546 --- /dev/null +++ b/public/assets/favicon.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/og-image.png b/public/assets/og-image.png new file mode 100644 index 00000000..e81325f6 Binary files /dev/null and b/public/assets/og-image.png differ diff --git a/public/index.html b/public/index.html index fe7379eb..2fba1be1 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,11 @@ - mashup-admin + Mash-Up Adminsoo | IT 연합 동아리 + + + + + + +
diff --git a/src/App.tsx b/src/App.tsx index 57829262..3033be4a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { ACCESS_TOKEN, PATH } from './constants'; import { LoginPage, + ApplicationList, ApplicationFormList, CreateApplicationForm, UpdateApplicationForm, @@ -63,6 +64,14 @@ const App = () => { }> + + + + } + /> { } /> - {/* // TODO:(용재) 추후 PATH.APPLICATION로 변경 */} - } - /> + } /> {/* // TODO:(용재) 추후 404로 변경 */} - } - /> + } /> + } diff --git a/src/api/application.ts b/src/api/application.ts index b25a1ab9..b6faa7e6 100644 --- a/src/api/application.ts +++ b/src/api/application.ts @@ -6,6 +6,8 @@ import { BaseResponse, ApplicationUpdateResultByIdRequest, ApplicationUpdateMultipleResultRequest, + ApplicationRequest, + ApplicationResponse, } from '@/types'; export const getApplicationById = ({ @@ -15,6 +17,14 @@ export const getApplicationById = ({ url: `/applications/${applicationId}`, }); +export const getApplications = ( + params: ApplicationRequest, +): Promise> => + http.get({ + url: '/applications', + params, + }); + export const postUpdateResult = ({ applicationId, applicationResultStatus, diff --git a/src/components/ApplicationDetail/ApplicationPanel/ApplicationPanel.component.tsx b/src/components/ApplicationDetail/ApplicationPanel/ApplicationPanel.component.tsx index 5bc7ebda..6039f9b2 100644 --- a/src/components/ApplicationDetail/ApplicationPanel/ApplicationPanel.component.tsx +++ b/src/components/ApplicationDetail/ApplicationPanel/ApplicationPanel.component.tsx @@ -181,14 +181,14 @@ const ControlArea = ({ confirmationStatus, resultStatus, interviewDate }: Contro {formatDate(date.format(), 'YYYY년 M월 D일(ddd) a hh시 mm분')} )} - + {/* - - {smsRequests?.map((each: MessageInfoProps) => ( - - ))} - + {smsRequests.length > 0 && ( + + {smsRequests.map((each: MessageInfoProps) => ( + + ))} + + )} ); }; diff --git a/src/components/ApplicationDetail/TitleWithContent/TitleWithContent.styled.ts b/src/components/ApplicationDetail/TitleWithContent/TitleWithContent.styled.ts index 21d244b3..8bb09a11 100644 --- a/src/components/ApplicationDetail/TitleWithContent/TitleWithContent.styled.ts +++ b/src/components/ApplicationDetail/TitleWithContent/TitleWithContent.styled.ts @@ -31,10 +31,12 @@ interface StyledContentProps { export const Content = styled.span` ${({ theme, isLineThrough }) => css` + ${theme.fonts.regular16} + width: 100%; margin-top: 0.4rem; color: ${theme.colors.gray80}; - font-size: 1.6rem; + font-weight: 500; line-height: 2.4rem; ${isLineThrough diff --git a/src/components/common/ApplicationStatusBadge/ApplicationStatusBadge.component.tsx b/src/components/common/ApplicationStatusBadge/ApplicationStatusBadge.component.tsx index 510aa753..8deea5fb 100644 --- a/src/components/common/ApplicationStatusBadge/ApplicationStatusBadge.component.tsx +++ b/src/components/common/ApplicationStatusBadge/ApplicationStatusBadge.component.tsx @@ -3,7 +3,7 @@ import { KeyOf, ValueOf } from '@/types'; import * as Styled from './ApplicationStatusBadge.styled'; export const ApplicationConfirmationStatus = { - TBD: '미검토', + TO_BE_DETERMINED: '미검토', NOT_APPLICABLE: '대상 없음', INTERVIEW_CONFIRM_WAITING: '면접 확인 대기', INTERVIEW_CONFIRM_ACCEPTED: '면접 수락', @@ -11,12 +11,11 @@ export const ApplicationConfirmationStatus = { FINAL_CONFIRM_WAITING: '최종 확인 대기', FINAL_CONFIRM_ACCEPTED: '최종 합격', FINAL_CONFIRM_REJECTED: '최종 거절', - TO_BE_DETERMINED: '미정', } as const; export const ApplicationResultStatus = { NOT_RATED: '미검토', - SCREENING_TBD: '서류 보류', + SCREENING_TO_BE_DETERMINED: '서류 보류', SCREENING_FAILED: '서류 불합격', SCREENING_PASSED: '서류 합격', INTERVIEW_FAILED: '최종 불합격', diff --git a/src/components/common/ApplicationStatusBadge/ApplicationStatusBadge.stories.tsx b/src/components/common/ApplicationStatusBadge/ApplicationStatusBadge.stories.tsx index 316a264b..b2a7fc33 100644 --- a/src/components/common/ApplicationStatusBadge/ApplicationStatusBadge.stories.tsx +++ b/src/components/common/ApplicationStatusBadge/ApplicationStatusBadge.stories.tsx @@ -23,7 +23,7 @@ NOT_RATED.args = { export const SCREENING_TBD = Template.bind({}); SCREENING_TBD.args = { - text: ApplicationResultStatus.SCREENING_TBD, + text: ApplicationResultStatus.SCREENING_TO_BE_DETERMINED, }; export const SCREENING_FAILED = Template.bind({}); diff --git a/src/components/common/SearchOptionBar/SearchOptionBar.component.tsx b/src/components/common/SearchOptionBar/SearchOptionBar.component.tsx index 36b25dd9..750f26d2 100644 --- a/src/components/common/SearchOptionBar/SearchOptionBar.component.tsx +++ b/src/components/common/SearchOptionBar/SearchOptionBar.component.tsx @@ -1,24 +1,117 @@ -import React, { useLayoutEffect, useRef } from 'react'; -import { Input } from '@/components'; +import React, { Dispatch, SetStateAction, useLayoutEffect, useMemo, useRef } from 'react'; +import { Input, Select } from '@/components'; import { ButtonSize, ButtonShape } from '@/components/common/Button/Button.component'; import * as Styled from './SearchOptionBar.styled'; +import { SelectOption, SelectSize } from '../Select/Select.component'; +import { + ApplicationConfirmationStatus, + ApplicationConfirmationStatusKeyType, + ApplicationResultStatus, + ApplicationResultStatusKeyType, +} from '../ApplicationStatusBadge/ApplicationStatusBadge.component'; + +export interface ApplicationFilterValuesType { + confirmStatus: SelectOption; + resultStatus: SelectOption; +} interface SearchOptionBarProps { searchWord: { value: string }; handleSubmit: React.FormEventHandler; + filterValues?: ApplicationFilterValuesType; + setFilterValues?: Dispatch>; } -const SearchOptionBar = ({ searchWord, handleSubmit }: SearchOptionBarProps) => { +const DEFAULT = { label: '전체', value: '' }; + +const SearchOptionBar = ({ + searchWord, + handleSubmit, + filterValues, + setFilterValues, +}: SearchOptionBarProps) => { const ref = useRef(null); + const handleApplicationConfirmStatus = (option: SelectOption) => { + setFilterValues?.((prev) => ({ + ...prev, + confirmStatus: option, + })); + }; + + const handleApplicationResultStatus = (option: SelectOption) => { + setFilterValues?.((prev) => ({ + ...prev, + resultStatus: option, + })); + }; + useLayoutEffect(() => { if (ref.current) { ref.current.value = searchWord.value; } }, [searchWord]); + const applicationConfirmStatusOptions = useMemo( + () => [ + DEFAULT, + ...Object.keys(ApplicationConfirmationStatus).reduce( + (acc, cur) => [ + ...acc, + { + label: ApplicationConfirmationStatus[cur as ApplicationConfirmationStatusKeyType], + value: cur, + }, + ], + [], + ), + ], + [], + ); + + const applicationResultStatusOptions = useMemo( + () => [ + DEFAULT, + ...Object.keys(ApplicationResultStatus).reduce( + (acc, cur) => [ + ...acc, + { + label: ApplicationResultStatus[cur as ApplicationResultStatusKeyType], + value: cur, + }, + ], + [], + ), + ], + [], + ); + return ( + {filterValues && ( + +
+
합격여부
+ +
+
+ )}
diff --git a/src/components/common/SearchOptionBar/SearchOptionBar.styled.ts b/src/components/common/SearchOptionBar/SearchOptionBar.styled.ts index b1106c99..aa3408c3 100644 --- a/src/components/common/SearchOptionBar/SearchOptionBar.styled.ts +++ b/src/components/common/SearchOptionBar/SearchOptionBar.styled.ts @@ -17,6 +17,25 @@ export const BarContainer = styled.form` `}; `; +export const SelectContainer = styled.div` + ${({ theme }) => css` + display: flex; + gap: 3.6rem; + + & > div { + ${theme.fonts.medium13} + display: flex; + gap: 1.2rem; + align-items: center; + color: ${theme.colors.gray70}; + + & span { + color: ${theme.colors.gray80}; + } + } + `} +`; + export const SearchInputContainer = styled.div` display: flex; gap: 0.4rem; diff --git a/src/components/common/Select/Select.component.tsx b/src/components/common/Select/Select.component.tsx index 7c7aad10..93e9e528 100644 --- a/src/components/common/Select/Select.component.tsx +++ b/src/components/common/Select/Select.component.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useRef, useState } from 'react'; +import React, { forwardRef, useEffect, useRef, useState } from 'react'; import * as Styled from './Select.styled'; import ChevronDown from '@/assets/svg/chevron-down-16.svg'; import { useOnClickOutSide } from '@/hooks'; @@ -31,6 +31,7 @@ export interface SelectProps { onChangeOption?: (option: SelectOption) => void; disabled?: boolean; defaultValue?: SelectOption; + currentValue?: SelectOption; } const Select = ( @@ -42,6 +43,7 @@ const Select = ( options, isFullWidth = false, defaultValue, + currentValue, onChangeOption, disabled = false, }: SelectProps, @@ -67,6 +69,12 @@ const Select = ( onChangeOption?.(option); }; + useEffect(() => { + if (currentValue) { + setSelectedOption(currentValue); + } + }, [currentValue]); + useOnClickOutSide(outerRef, () => setOpened(false)); return ( diff --git a/src/components/common/SmsSendModalDialog/SmsSendModalDialog.component.tsx b/src/components/common/SmsSendModalDialog/SmsSendModalDialog.component.tsx index 321c3c01..ab01a9d0 100644 --- a/src/components/common/SmsSendModalDialog/SmsSendModalDialog.component.tsx +++ b/src/components/common/SmsSendModalDialog/SmsSendModalDialog.component.tsx @@ -1,10 +1,11 @@ +// TODO:(용재) 추후 SMS 관련 로직 성공 후 주석 제거 import React from 'react'; import { useRecoilCallback } from 'recoil'; import { useForm } from 'react-hook-form'; -import { useNavigate } from 'react-router-dom'; +// import { useNavigate } from 'react-router-dom'; import { InputField, ModalWrapper, TitleWithContent, Textarea } from '@/components'; import * as Styled from './SmsSendModalDialog.styled'; -import * as api from '@/api'; +// import * as api from '@/api'; import { $modalByStorage, ModalKey } from '@/store'; import ApplicationStatusBadge, { ApplicationConfirmationStatus, @@ -13,10 +14,10 @@ import ApplicationStatusBadge, { ApplicationResultStatusKeyType, } from '@/components/common/ApplicationStatusBadge/ApplicationStatusBadge.component'; import ArrowRight from '@/assets/svg/chevron-right-16.svg'; -import { request } from '@/utils'; -import { useToast } from '@/hooks'; -import { ToastType } from '../Toast/Toast.component'; -import { PATH } from '@/constants'; +// import { request } from '@/utils'; +// import { useToast } from '@/hooks'; +// import { ToastType } from '../Toast/Toast.component'; +// import { PATH } from '@/constants'; interface FormValues { name: string; @@ -34,8 +35,8 @@ const SmsSendModalDialog = ({ resultStatus, confirmationStatus, }: SmsSendModalDialogProps) => { - const { handleAddToast } = useToast(); - const navigate = useNavigate(); + // const { handleAddToast } = useToast(); + // const navigate = useNavigate(); const handleRemoveCurrentModal = useRecoilCallback(({ set }) => () => { set($modalByStorage(ModalKey.smsSendModalDialog), { @@ -44,42 +45,43 @@ const SmsSendModalDialog = ({ }); }); - const { handleSubmit, register } = useForm(); + const { register } = useForm(); + // const { handleSubmit, register } = useForm(); - const handleSendSms = useRecoilCallback(({ set }) => ({ content, name }: FormValues) => { - set($modalByStorage(ModalKey.alertModalDialog), { - key: ModalKey.alertModalDialog, - isOpen: true, - props: { - heading: '발송하시겠습니까?', - paragraph: 'SMS 발송내역에서 확인하실 수 있습니다.', - confirmButtonLabel: '발송', - handleClickConfirmButton: () => { - request({ - requestFunc: async () => { - await api.postSmsSend({ applicantIds: selectedList, content, name }); - }, + // const handleSendSms = useRecoilCallback(({ set }) => ({ content, name }: FormValues) => { + // set($modalByStorage(ModalKey.alertModalDialog), { + // key: ModalKey.alertModalDialog, + // isOpen: true, + // props: { + // heading: '발송하시겠습니까?', + // paragraph: 'SMS 발송내역에서 확인하실 수 있습니다.', + // confirmButtonLabel: '발송', + // handleClickConfirmButton: () => { + // request({ + // requestFunc: async () => { + // await api.postSmsSend({ applicantIds: selectedList, content, name }); + // }, - errorHandler: handleAddToast, - onSuccess: () => { - handleRemoveCurrentModal(); - handleAddToast({ - type: ToastType.success, - message: 'SMS 발송 완료', - }); - navigate(PATH.SMS); - }, - onCompleted: () => { - set($modalByStorage(ModalKey.alertModalDialog), { - key: ModalKey.alertModalDialog, - isOpen: false, - }); - }, - }); - }, - }, - }); - }); + // errorHandler: handleAddToast, + // onSuccess: () => { + // handleRemoveCurrentModal(); + // handleAddToast({ + // type: ToastType.success, + // message: 'SMS 발송 완료', + // }); + // navigate(PATH.SMS); + // }, + // onCompleted: () => { + // set($modalByStorage(ModalKey.alertModalDialog), { + // key: ModalKey.alertModalDialog, + // isOpen: false, + // }); + // }, + // }); + // }, + // }, + // }); + // }); const props = { heading: 'SMS 발송', @@ -87,11 +89,11 @@ const SmsSendModalDialog = ({ cancelButton: { label: '취소', }, - confirmButton: { - label: '발송', - onClick: handleSubmit(handleSendSms), - type: 'submit', - }, + // confirmButton: { + // label: '발송', + // onClick: handleSubmit(handleSendSms), + // type: 'submit', + // }, }, handleCloseModal: handleRemoveCurrentModal, }; diff --git a/src/components/common/Table/Table.component.tsx b/src/components/common/Table/Table.component.tsx index 9b6415e1..33df657e 100644 --- a/src/components/common/Table/Table.component.tsx +++ b/src/components/common/Table/Table.component.tsx @@ -17,6 +17,8 @@ import Loading from '../Loading/Loading.component'; import Checkbox from '../Checkbox/Checkbox.component'; import { SORT_TYPE } from '@/constants'; +const NAV_BAR_AND_SEARCH_BAR_HEIGHT = 187; +const TABLE_DEFAULT_HEIGHT = (window.innerHeight - NAV_BAR_AND_SEARCH_BAR_HEIGHT) / 10; export interface TableColumn { title: string; accessor?: NestedKeyOf; @@ -212,7 +214,7 @@ const TableColumnCell = ({ const Table = ({ prefix, - maxHeight, + maxHeight = TABLE_DEFAULT_HEIGHT, columns, rows, isLoading, @@ -233,8 +235,7 @@ const Table = ({ selectedRows ? rows.map((row) => selectedRows.some((selectedRow) => isSameObject(selectedRow, row))) : [], - // eslint-disable-next-line react-hooks/exhaustive-deps - [rows], + [selectedRows, rows], ); const allInAPageChecked = useMemo( () => !!rows.length && checkedValues.filter(Boolean).length === rows.length, @@ -302,9 +303,6 @@ const Table = ({ - {!isEmptyData && isLoading && ( - - )} {isEmptyData ? ( @@ -328,6 +326,12 @@ const Table = ({ ))} + {isLoading && ( + + )} {rows.map((row, rowIndex) => ( {!!selectableRow && ( diff --git a/src/components/common/Table/Table.styled.ts b/src/components/common/Table/Table.styled.ts index 4fd9f3ce..eaf82c68 100644 --- a/src/components/common/Table/Table.styled.ts +++ b/src/components/common/Table/Table.styled.ts @@ -69,6 +69,8 @@ export const TableHeader = styled.thead` export const TableBody = styled.tbody<{ isEmpty?: boolean }>` ${({ theme, isEmpty }) => css` + position: relative; + & tr { &:hover { background-color: ${isEmpty ? 'transparent' : theme.colors.purple20}; @@ -172,8 +174,8 @@ export const TableSummary = styled.div` } & > div:nth-of-type(2) { - ${theme.fonts.regular14} - color: ${theme.colors.gray60} + ${theme.fonts.medium14} + color: ${theme.colors.gray70} } & > div:nth-of-type(4) { @@ -214,6 +216,7 @@ export const TotalSelectBox = styled.div` ${theme.fonts.medium13}; padding: 0; color: ${theme.colors.purple70}; + background-color: transparent; } `}; `; @@ -235,6 +238,12 @@ export const TableSupportButtonContainer = styled.div` & button { margin-right: 0.4rem; + + /* TODO: (@minsour) 버튼에 함수 바인딩할때 제거 */ + cursor: not-allowed; + &:hover { + background-color: inherit; + } } & button:last-child { diff --git a/src/components/common/TeamNavigationTabs/TeamNavigationTabs.component.tsx b/src/components/common/TeamNavigationTabs/TeamNavigationTabs.component.tsx index b3c42031..6b54b71d 100644 --- a/src/components/common/TeamNavigationTabs/TeamNavigationTabs.component.tsx +++ b/src/components/common/TeamNavigationTabs/TeamNavigationTabs.component.tsx @@ -10,6 +10,7 @@ const TeamNavigationTabs = () => { const handleClickTab = (teamParam: string) => { if (teamParam) { searchParams.set('team', teamParam); + searchParams.delete('page'); } else { searchParams.delete('team'); } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 2b9f5cbf..f2646b51 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,3 +4,4 @@ export { default as usePagination } from './usePagination'; export { default as useMount } from './useMount'; export { default as useToast } from './useToast'; export { default as useUnmount } from './useUnmount'; +export { default as useDirty } from './useDirty'; diff --git a/src/hooks/useDirty.ts b/src/hooks/useDirty.ts new file mode 100644 index 00000000..4d4798ed --- /dev/null +++ b/src/hooks/useDirty.ts @@ -0,0 +1,15 @@ +import { useState } from 'react'; + +const useDirty = (chance = 0) => { + const [count, setCount] = useState(0); + + const makeDirty = () => setCount((prev) => prev + 1); + const isDirty = count > chance; + + return { + makeDirty, + isDirty, + }; +}; + +export default useDirty; diff --git a/src/pages/ApplicationDetailView/ApplicationDetailView.page.tsx b/src/pages/ApplicationDetailView/ApplicationDetailView.page.tsx index ebcf180b..27d4ed54 100644 --- a/src/pages/ApplicationDetailView/ApplicationDetailView.page.tsx +++ b/src/pages/ApplicationDetailView/ApplicationDetailView.page.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; import { BackButton } from '@/components'; import * as Styled from './ApplicationDetailView.styled'; @@ -12,8 +12,10 @@ import { } from '@/components/ApplicationDetail'; import { $applicationById } from '@/store'; import { ApplicationByIdResponseData, Question } from '@/types'; +import { PATH } from '@/constants'; const ApplicationDetailView = () => { + const navigate = useNavigate(); const { id } = useParams(); const data = useRecoilValue( $applicationById({ applicationId: id as string }), @@ -22,7 +24,7 @@ const ApplicationDetailView = () => { return (
- + navigate(PATH.APPLICATION)} /> 지원설문지 상세
@@ -32,10 +34,21 @@ const ApplicationDetailView = () => {
{data.applicant.name} - {data.team.name} + {data.applicant.email} + {data.applicant.residence}
{data.applicant.phoneNumber} + {data.applicant.birthdate} + {data.applicant.department} +
+
+ +
+
+ {data.team.name} +
+
{formatDate(data.applicant.updatedAt, 'YYYY년 M월 D일(ddd)')} @@ -45,8 +58,8 @@ const ApplicationDetailView = () => {

질문 리스트

- {data?.questions.map((each: Question) => ( - + {data?.questions.map((each: Question, index) => ( + ))}
diff --git a/src/pages/ApplicationDetailView/ApplicationDetailView.styled.ts b/src/pages/ApplicationDetailView/ApplicationDetailView.styled.ts index a3a9c6e1..eee767b0 100644 --- a/src/pages/ApplicationDetailView/ApplicationDetailView.styled.ts +++ b/src/pages/ApplicationDetailView/ApplicationDetailView.styled.ts @@ -39,7 +39,7 @@ export const ApplicantInfo = styled.div` & > div { display: flex; flex-direction: column; - gap: 2.8rem; + gap: 2rem; width: 50%; } } @@ -79,8 +79,9 @@ export const Aside = styled.aside` export const Divider = styled.div` ${({ theme }) => css` + width: 100%; height: 0.1rem; - margin: 0 3.2rem; + margin-top: 2rem; background-color: ${theme.colors.gray30}; `} `; diff --git a/src/pages/ApplicationFormList/ApplicationFormList.page.tsx b/src/pages/ApplicationFormList/ApplicationFormList.page.tsx index cd6103fe..5e79bae5 100644 --- a/src/pages/ApplicationFormList/ApplicationFormList.page.tsx +++ b/src/pages/ApplicationFormList/ApplicationFormList.page.tsx @@ -1,5 +1,5 @@ -import React, { FormEvent, useEffect, useMemo, useState } from 'react'; -import { useRecoilStateLoadable, useRecoilValue, useRecoilRefresher_UNSTABLE } from 'recoil'; +import React, { FormEvent, useEffect, useLayoutEffect, useMemo, useState, useRef } from 'react'; +import { useRecoilStateLoadable, useRecoilValue } from 'recoil'; import { useSearchParams } from 'react-router-dom'; import Preview from '@/assets/svg/preview-20.svg'; @@ -13,15 +13,15 @@ import { UserProfile, SearchOptionBar, } from '@/components'; -import { usePagination, useToggleState } from '@/hooks'; +import { useDirty, usePagination, useToggleState } from '@/hooks'; import { $applicationForms, $teamIdByName } from '@/store'; import { ApplicationFormResponse, Question, ApplicationFormRequest } from '@/types'; -import { PATH } from '@/constants'; +import { PATH, SORT_TYPE } from '@/constants'; import { formatDate } from '@/utils'; -import { TableColumn } from '@/components/common/Table/Table.component'; +import { TableColumn, SortType } from '@/components/common/Table/Table.component'; import { TeamType, RoleType } from '@/components/common/UserProfile/UserProfile.component'; import { ApplicationFormPreviewModal } from '@/components/ApplicationForm/ApplicationFormPreview/ApplicationFormPreview.component'; -import { ButtonShape } from '@/components/common/Button/Button.component'; +import { ButtonShape, ButtonSize } from '@/components/common/Button/Button.component'; import * as Styled from './ApplicationFormList.styled'; @@ -95,35 +95,54 @@ const ApplicationFormList = () => { const [searchParams] = useSearchParams(); const teamName = searchParams.get('team'); const teamId = useRecoilValue($teamIdByName(teamName)); + const teamTabRef = useRef(null); const page = searchParams.get('page') || '1'; const size = searchParams.get('size') || '20'; const [searchWord, setSearchWord] = useState<{ value: string }>({ value: '' }); + + const [sortTypes, setSortTypes] = useState[]>([ + { accessor: 'team.name', type: SORT_TYPE.DEFAULT }, + { accessor: 'name', type: SORT_TYPE.DEFAULT }, + { accessor: 'createdAt', type: SORT_TYPE.DEFAULT }, + { accessor: 'updatedAt', type: SORT_TYPE.DEFAULT }, + ]); + const sortParam = useMemo(() => { + const matched = sortTypes.find((sortType) => sortType.type !== SORT_TYPE.DEFAULT); + if (!matched) return ''; + + const { accessor, type } = matched; + return `${accessor},${type}`; + }, [sortTypes]); + const applicationFormParams = useMemo( () => ({ page: parseInt(page, 10) - 1, size: parseInt(size, 10), teamId: parseInt(teamId, 10) || undefined, searchWord: searchWord.value, + sort: sortParam, }), - [page, size, teamId, searchWord], + [page, size, teamId, searchWord, sortParam], ); + const [totalCount, setTotalCount] = useState(0); const [{ state, contents: tableRows }] = useRecoilStateLoadable( $applicationForms(applicationFormParams), ); - const refreshApplicationForms = useRecoilRefresher_UNSTABLE( - $applicationForms(applicationFormParams), - ); const isLoading = state === 'loading'; - const [loadedTableRows, setLoadedTableRows] = useState([]); + const [loadedTableRows, setLoadedTableRows] = useState( + tableRows.data || [], + ); const { pageOptions, handleChangePage, handleChangeSize } = usePagination( tableRows.page?.totalCount, ); + const { makeDirty, isDirty } = useDirty(1); + const handleSubmit = ( e: { target: { searchWord: { value: string } } } & FormEvent, ) => { @@ -134,42 +153,54 @@ const ApplicationFormList = () => { useEffect(() => { if (!isLoading) { setLoadedTableRows(tableRows.data); + setTotalCount(tableRows.page.totalCount); + makeDirty(); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoading, tableRows]); useEffect(() => { - refreshApplicationForms(); + setSearchWord({ value: '' }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [page, size, searchWord]); + }, [teamName]); - useEffect(() => { - refreshApplicationForms(); - setSearchWord({ value: '' }); + useLayoutEffect(() => { + if (teamTabRef.current && isDirty && !isLoading) { + teamTabRef.current.scrollIntoView(); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [teamId]); + }, [loadedTableRows]); return ( 지원서 설문지 내역 - +
+ +
- prefix="application" + prefix="application-form" columns={columns} rows={loadedTableRows} - maxHeight={72} isLoading={isLoading} supportBar={{ - totalCount: tableRows.page?.totalCount, + totalCount, totalSummaryText: '총 지원설문지', buttons: [ - , ], }} + sortOptions={{ + sortTypes, + disableMultiSort: true, + handleSortColumn: (_sortTypes) => { + setSortTypes(_sortTypes); + }, + }} pagination={ [] = [ + { + title: '이름', + accessor: 'applicant.name', + idAccessor: 'applicationId', + widthRatio: '10%', + renderCustomCell: (cellValue, id) => ( + + {cellValue as string} + + + ), + }, + { + title: '전화번호', + accessor: 'applicant.phoneNumber', + widthRatio: '14%', + }, + { + title: '지원플랫폼', + accessor: 'team.name', + widthRatio: '8%', + }, + { + title: '지원일시', + accessor: 'updatedAt', + widthRatio: '21%', + renderCustomCell: (cellValue) => + cellValue ? formatDate(cellValue as string, 'YYYY년 M월 D일 A h시 m분') : '-', + }, + { + title: '면접일시', + accessor: 'result.interviewStartedAt', + widthRatio: '21%', + renderCustomCell: (cellValue) => + cellValue ? formatDate(cellValue as string, 'YYYY년 M월 D일 A h시 m분') : '-', + }, + { + title: '사용자확인여부', + accessor: 'confirmationStatus', + widthRatio: '13%', + renderCustomCell: (cellValue) => ( + + + + ), + }, + { + title: '합격여부', + accessor: 'result.status', + widthRatio: '13%', + renderCustomCell: (cellValue) => ( + + + + ), + }, +]; + +const ApplicationList = () => { + const [searchParams] = useSearchParams(); + const teamName = searchParams.get('team'); + const teamId = useRecoilValue($teamIdByName(teamName)); + const teamTabRef = useRef(null); + + const page = searchParams.get('page') || '1'; + const size = searchParams.get('size') || '20'; + + const [searchWord, setSearchWord] = useState<{ value: string }>({ value: '' }); + const [filterValues, setFilterValues] = useState({ + confirmStatus: { label: '', value: '' }, + resultStatus: { label: '', value: '' }, + }); + + const [sortTypes, setSortTypes] = useState[]>([ + { accessor: 'applicant.name', type: SORT_TYPE.DEFAULT }, + { accessor: 'updatedAt', type: SORT_TYPE.DEFAULT }, + { accessor: 'result.interviewStartedAt', type: SORT_TYPE.DEFAULT }, + ]); + const sortParam = useMemo(() => { + const matched = sortTypes.find((sortType) => sortType.type !== SORT_TYPE.DEFAULT); + if (!matched) return ''; + + const { accessor, type } = matched; + const accessorKeys = accessor.split('.'); + + if (accessorKeys[0] === 'result') { + const resultAccessor = ['applicationResult'].concat(accessorKeys.slice(1)).join('.'); + return `${resultAccessor},${type}`; + } + + return `${accessor},${type}`; + }, [sortTypes]); + + const applicationParams = useMemo( + () => ({ + page: parseInt(page, 10) - 1, + size: parseInt(size, 10), + teamId: parseInt(teamId, 10) || undefined, + searchWord: searchWord.value, + confirmStatus: filterValues?.confirmStatus?.value, + resultStatus: filterValues?.resultStatus?.value, + sort: sortParam, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [page, size, teamId, searchWord, sortParam], + ); + + const [totalCount, setTotalCount] = useState(0); + const [{ state, contents: tableRows }] = useRecoilStateLoadable($applications(applicationParams)); + const [selectedRows, setSelectedRows] = useState([]); + + const isLoading = state === 'loading'; + const [loadedTableRows, setLoadedTableRows] = useState( + tableRows.data || [], + ); + + const { pageOptions, handleChangePage, handleChangeSize } = usePagination( + tableRows.page?.totalCount, + ); + + const { makeDirty, isDirty } = useDirty(1); + + const handleSearch = ( + e: { target: { searchWord: { value: string } } } & FormEvent, + ) => { + e.preventDefault(); + setSearchWord({ value: e.target.searchWord.value }); + }; + + const handleSelectAll = useCallback( + async (checkedValue) => { + if (checkedValue) { + setSelectedRows([]); + } else { + const applications = await api.getApplications({ + page: 0, + size: tableRows.page.totalCount + APPLICATION_EXTRA_SIZE, + }); + setSelectedRows(applications.data); + if (applications.page) { + setTotalCount(applications.page.totalCount); + } + } + }, + [tableRows.page?.totalCount], + ); + + useEffect(() => { + if (!isLoading) { + setLoadedTableRows(tableRows.data); + setTotalCount(tableRows.page.totalCount); + makeDirty(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoading, tableRows]); + + useEffect(() => { + setSearchWord({ value: '' }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [teamName]); + + useLayoutEffect(() => { + if (teamTabRef.current && isDirty && !isLoading) { + teamTabRef.current.scrollIntoView(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loadedTableRows]); + + return ( + + 지원서 내역 +
+ +
+ + + SMS 발송 + , + , + , + ], + }} + selectableRow={{ + selectedCount: selectedRows.length, + selectedRows, + setSelectedRows, + handleSelectAll, + }} + sortOptions={{ + sortTypes, + disableMultiSort: true, + handleSortColumn: (_sortTypes) => { + setSortTypes(_sortTypes); + }, + }} + pagination={ + 3 ? 'top' : 'bottom'} + handleChangePage={handleChangePage} + handleChangeSize={handleChangeSize} + /> + } + /> + + ); +}; + +export default ApplicationList; diff --git a/src/pages/ApplicationList/ApplicationList.styled.ts b/src/pages/ApplicationList/ApplicationList.styled.ts new file mode 100644 index 00000000..ec789ce2 --- /dev/null +++ b/src/pages/ApplicationList/ApplicationList.styled.ts @@ -0,0 +1,55 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { Link } from '@/components'; + +export const PageWrapper = styled.div` + padding: 4rem 0; +`; + +export const Heading = styled.h2` + ${({ theme }) => css` + margin-bottom: 2.4rem; + color: ${theme.colors.gray80}; + font-weight: 700; + font-size: 3.6rem; + line-height: 4.5rem; + `}; +`; + +export const FormTitleWrapper = styled.div` + ${({ theme }) => css` + position: relative; + + &:hover { + color: ${theme.colors.purple90}; + font-weight: 500; + text-decoration: underline; + text-underline-position: under; + } + `}; +`; + +export const FormTitle = styled.div` + ${({ theme }) => css` + width: 100%; + height: 100%; + padding: 0 1rem; + overflow: hidden; + color: ${theme.colors.purple80}; + line-height: 5.2rem; + white-space: nowrap; + text-align: center; + text-overflow: ellipsis; + `}; +`; + +export const TitleLink = styled(Link)` + position: absolute; + inset: 0; +`; + +export const Center = styled.div` + display: flex; + align-items: center; + justify-content: center; +`; diff --git a/src/pages/index.ts b/src/pages/index.ts index f8a29bbe..a862306a 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -4,3 +4,4 @@ export { default as CreateApplicationForm } from './CreateApplicationForm/Create export { default as ApplicationFormDetail } from './ApplicationFormDetail/ApplicationFormDetail.page'; export { default as UpdateApplicationForm } from './UpdateApplicationForm/UpdateApplicationForm.page'; export { default as ApplicationFormList } from './ApplicationFormList/ApplicationFormList.page'; +export { default as ApplicationList } from './ApplicationList/ApplicationList.page'; diff --git a/src/store/application.ts b/src/store/application.ts index 816c0e83..aec4784d 100644 --- a/src/store/application.ts +++ b/src/store/application.ts @@ -1,6 +1,12 @@ -import { ApplicationByIdRequest, ApplicationByIdResponseData } from '@/types/dto'; +import { + ApplicationByIdRequest, + ApplicationByIdResponseData, + ApplicationRequest, + ApplicationResponse, + BaseResponse, +} from '@/types'; import { selectorFamilyWithRefresher } from './recoil'; -import { getApplicationById } from '@/api'; +import * as api from '@/api'; export const $applicationById = selectorFamilyWithRefresher< ApplicationByIdResponseData, @@ -8,8 +14,19 @@ export const $applicationById = selectorFamilyWithRefresher< >({ key: 'applicationById', get: (params) => async () => { - const { data } = await getApplicationById(params); + const { data } = await api.getApplicationById(params); return data; }, }); + +export const $applications = selectorFamilyWithRefresher< + BaseResponse, + ApplicationRequest +>({ + key: 'applications', + get: (params) => async () => { + const data = await api.getApplications(params); + return data; + }, +}); diff --git a/src/store/applicationForm.ts b/src/store/applicationForm.ts index bdc4f75a..8e76c84b 100644 --- a/src/store/applicationForm.ts +++ b/src/store/applicationForm.ts @@ -18,7 +18,7 @@ export const $applicationForms = selectorFamilyWithRefresher< BaseResponse, ApplicationFormRequest >({ - key: 'applications', + key: 'applicationForms', get: (params) => async () => { const data = await api.getApplicationForms(params); return data; diff --git a/src/styles/resetCss.ts b/src/styles/resetCss.ts index 0a0276f3..2b913db4 100644 --- a/src/styles/resetCss.ts +++ b/src/styles/resetCss.ts @@ -8,6 +8,7 @@ const resetCss = css` html { /* 1rem = 10px */ font-size: 62.5%; + scroll-behavior: smooth; } html, diff --git a/src/styles/themes/badge.ts b/src/styles/themes/badge.ts index 83f55bdf..17b359b5 100644 --- a/src/styles/themes/badge.ts +++ b/src/styles/themes/badge.ts @@ -11,7 +11,7 @@ export const badge = { color: ${colors.gray60}; background-color: ${colors.gray20}; `, - [ApplicationResultStatus.SCREENING_TBD]: css` + [ApplicationResultStatus.SCREENING_TO_BE_DETERMINED]: css` color: ${colors.blue70}; background-color: ${colors.blue20}; `, @@ -31,7 +31,7 @@ export const badge = { color: ${colors.white}; background-color: ${colors.green70}; `, - [ApplicationConfirmationStatus.TBD]: css` + [ApplicationConfirmationStatus.TO_BE_DETERMINED]: css` color: ${colors.gray60}; background-color: ${colors.gray20}; `, diff --git a/src/types/dto/application.ts b/src/types/dto/application.ts index 28ba0455..538b39ad 100644 --- a/src/types/dto/application.ts +++ b/src/types/dto/application.ts @@ -2,7 +2,6 @@ import { ValueOf, Team, Question } from '@/types'; import { SmsContent } from './sms'; export const ApplicationConfirmationStatusInDto = { - TBD: 'TBD', NOT_APPLICABLE: 'NOT_APPLICABLE', INTERVIEW_CONFIRM_WAITING: 'INTERVIEW_CONFIRM_WAITING', INTERVIEW_CONFIRM_ACCEPTED: 'INTERVIEW_CONFIRM_ACCEPTED', @@ -15,7 +14,7 @@ export const ApplicationConfirmationStatusInDto = { export const ApplicationResultStatusInDto = { NOT_RATED: 'NOT_RATED', - SCREENING_TBD: 'SCREENING_TBD', + SCREENING_TBD: 'SCREENING_TO_BE_DETERMINED', SCREENING_FAILED: 'SCREENING_FAILED', SCREENING_PASSED: 'SCREENING_PASSED', INTERVIEW_FAILED: 'INTERVIEW_FAILED', @@ -66,6 +65,12 @@ export const ApplicantStatus = { export type ApplicantStatusType = ValueOf; +export interface Answer { + answerId: number; + content: string; + questionId: number; +} + export interface ApplicationByIdRequest { applicationId: string; } @@ -82,17 +87,16 @@ export interface ApplicationUpdateMultipleResultRequest { } export interface ApplicationByIdResponseData extends Array { - answers: { - answerId: number; - content: string; - questionId: number; - }[]; + answers: Answer[]; applicant: { applicantId: number; + birthdate: string; createdAt: string; + department: string; email: string; name: string; phoneNumber: string; + residence: string; status: ApplicantStatusType; updatedAt: string; }; diff --git a/src/types/dto/applicationForm.ts b/src/types/dto/applicationForm.ts index 26272d54..2f89915d 100644 --- a/src/types/dto/applicationForm.ts +++ b/src/types/dto/applicationForm.ts @@ -12,7 +12,7 @@ export interface Question { maxContentLength: number | null; questionType: QuestionKindType; required: boolean; - questionId?: string; + questionId?: number; } export interface ApplicationFormRequest { diff --git a/webpack/webpack.prod.js b/webpack/webpack.prod.js index 6229645c..b291ffb7 100644 --- a/webpack/webpack.prod.js +++ b/webpack/webpack.prod.js @@ -19,6 +19,10 @@ module.exports = merge(baseConfig, { new CopyPlugin({ patterns: [ { context: path.resolve(PROJECT_ROOT, 'public/fonts'), from: '*.woff2', to: 'fonts' }, + { + from: path.resolve(PROJECT_ROOT, 'public/', 'assets'), + to: path.resolve(PROJECT_ROOT, 'dist/', 'assets'), + }, ], }), ],