diff --git a/package.json b/package.json index ec6ffe48..411cfa16 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "dependencies": { "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", - "@sopt-makers/colors": "^2.2.0", + "@sopt-makers/colors": "^3.0.0", + "@sopt-makers/fonts": "^1.0.0", "@types/axios": "^0.14.0", "@types/node": "18.15.3", "@types/react": "18.0.28", diff --git a/public/favicon.ico b/public/favicon.ico index 718d6fea..3a5553fe 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/src/assets/icons/IcAlarmMenu.svg b/src/assets/icons/IcAlarmMenu.svg new file mode 100644 index 00000000..93f3dde6 --- /dev/null +++ b/src/assets/icons/IcAlarmMenu.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/assets/icons/IcAttendanceMenu.svg b/src/assets/icons/IcAttendanceMenu.svg new file mode 100644 index 00000000..93e1d120 --- /dev/null +++ b/src/assets/icons/IcAttendanceMenu.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/IcDate.svg b/src/assets/icons/IcDate.svg new file mode 100644 index 00000000..d30f45eb --- /dev/null +++ b/src/assets/icons/IcDate.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/IcDeleteFile.svg b/src/assets/icons/IcDeleteFile.svg new file mode 100644 index 00000000..90b1f22d --- /dev/null +++ b/src/assets/icons/IcDeleteFile.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/assets/icons/IcDropdownCheck.svg b/src/assets/icons/IcDropdownCheck.svg new file mode 100644 index 00000000..e2f49cc5 --- /dev/null +++ b/src/assets/icons/IcDropdownCheck.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/IcModalClose.svg b/src/assets/icons/IcModalClose.svg index 7866f77f..e2daaac7 100644 --- a/src/assets/icons/IcModalClose.svg +++ b/src/assets/icons/IcModalClose.svg @@ -1,3 +1,4 @@ - - - + + + + \ No newline at end of file diff --git a/src/assets/icons/IcMore.svg b/src/assets/icons/IcMore.svg new file mode 100644 index 00000000..41cf151b --- /dev/null +++ b/src/assets/icons/IcMore.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/icons/IcNavMenu.svg b/src/assets/icons/IcNavMenu.svg deleted file mode 100644 index fefaaafb..00000000 --- a/src/assets/icons/IcNavMenu.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/src/assets/icons/IcNewDropdown.svg b/src/assets/icons/IcNewDropdown.svg new file mode 100644 index 00000000..879c9903 --- /dev/null +++ b/src/assets/icons/IcNewDropdown.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/IcOrgMenu.svg b/src/assets/icons/IcOrgMenu.svg new file mode 100644 index 00000000..407d7e06 --- /dev/null +++ b/src/assets/icons/IcOrgMenu.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/IcPlace.svg b/src/assets/icons/IcPlace.svg new file mode 100644 index 00000000..e437d92d --- /dev/null +++ b/src/assets/icons/IcPlace.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/IcUpload.svg b/src/assets/icons/IcUpload.svg new file mode 100644 index 00000000..9d0cb1bd --- /dev/null +++ b/src/assets/icons/IcUpload.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/SoptLogos/DoSoptLogo.svg b/src/assets/icons/SoptLogos/DoSoptLogo.svg new file mode 100644 index 00000000..2a0a1e03 --- /dev/null +++ b/src/assets/icons/SoptLogos/DoSoptLogo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/SoptLogos/GoSoptLogo.svg b/src/assets/icons/SoptLogos/GoSoptLogo.svg new file mode 100644 index 00000000..6b3a8758 --- /dev/null +++ b/src/assets/icons/SoptLogos/GoSoptLogo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/SoptLogos/SoptMainLogo.svg b/src/assets/icons/SoptLogos/SoptMainLogo.svg new file mode 100644 index 00000000..a4494ade --- /dev/null +++ b/src/assets/icons/SoptLogos/SoptMainLogo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/SoptLogos/index.ts b/src/assets/icons/SoptLogos/index.ts new file mode 100644 index 00000000..78400f8d --- /dev/null +++ b/src/assets/icons/SoptLogos/index.ts @@ -0,0 +1,3 @@ +export { default as DoSoptLogo } from './DoSoptLogo.svg'; +export { default as GoSoptLogo } from './GoSoptLogo.svg'; +export { default as SoptMainLogo } from './SoptMainLogo.svg'; diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index 99aaf718..6eca17b6 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -1,4 +1,9 @@ +export { default as IcAlarmMenu } from './IcAlarmMenu.svg'; +export { default as IcAttendanceMenu } from './IcAttendanceMenu.svg'; export { default as IcCheckBox } from './IcCheckBox'; +export { default as IcDeleteFile } from './IcDeleteFile.svg'; export { default as IcGoPrev } from './IcGoPrev.svg'; export { default as IcModalClose } from './IcModalClose.svg'; -export { default as IcNavMenu } from './IcNavMenu.svg'; +export { default as IcNewDropdown } from './IcNewDropdown.svg'; +export { default as IcOrgMenu } from './IcOrgMenu.svg'; +export { default as IcUpload } from './IcUpload.svg'; diff --git a/src/components/alarmAdmin/AlarmList/index.tsx b/src/components/alarmAdmin/AlarmList/index.tsx new file mode 100644 index 00000000..e45852d5 --- /dev/null +++ b/src/components/alarmAdmin/AlarmList/index.tsx @@ -0,0 +1,160 @@ +import dayjs from 'dayjs'; +import { useState } from 'react'; +import { UseQueryResult } from 'react-query'; + +import IcMore from '@/assets/icons/IcMore.svg'; +import { StActionButton } from '@/components/attendanceAdmin/session/SessionList/style'; +import Chip from '@/components/common/Chip'; +import FilterButton from '@/components/common/FilterButton'; +import ListActionButton from '@/components/common/ListActionButton'; +import ListWrapper from '@/components/common/ListWrapper'; +import Loading from '@/components/common/Loading'; +import Modal from '@/components/common/modal'; +import { deleteAlarm, sendAlarm } from '@/services/api/alarm'; + +import ShowAlarmModal from '../ShowAlarmModal'; +import { StListItem, StPageHeader } from './style'; + +const sendStatusList: ALARM_STATUS[] = ['전체', '발송 전', '발송 후']; + +interface Props { + data: Alarm[]; + refetch: () => void; +} + +function AlarmList(props: Props) { + const { data, refetch } = props; + + const [tab, setTab] = useState('전체'); + const [activeDropdownId, setActiveDropdownId] = useState(null); + const [showAlarmDetail, setShowAlarmDetail] = useState(null); + const [isSending, setIsSending] = useState(false); + + const onChangeTab = (value: ALARM_STATUS) => { + setTab(value); + }; + + const toggleDropdown = (e: React.MouseEvent, alarmId: number): void => { + e.stopPropagation(); + if (activeDropdownId === alarmId) { + setActiveDropdownId(null); + } else { + setActiveDropdownId(alarmId); + } + }; + + const onSendAlarm = async (alarmId: number, title: string) => { + const response = window.confirm(`${title} 알림을 전송하시겠습니까?`); + if (response) { + setIsSending(true); + const result = await sendAlarm(alarmId); + setIsSending(false); + window.alert(result ? '전송에 성공했습니다' : '전송에 실패했습니다'); + refetch(); + } + }; + + const onShowAlarmDetail = (alarmId: number) => { + setShowAlarmDetail(alarmId); + }; + + const onCloseAlarmDetail = () => { + setShowAlarmDetail(null); + }; + + const handleDeleteAlarm = async ( + e: React.MouseEvent, + alarmId: number, + ) => { + e.stopPropagation(); + const response = window.confirm('알림을 삭제하시겠습니까?'); + if (response) { + const result = await deleteAlarm(alarmId); + result && refetch(); + } + }; + + const alarmList = data + ? data.filter((item) => (tab === '전체' ? true : item.status === tab)) + : []; + + return ( + <> + +

알림 관리

+ + list={sendStatusList} + selected={tab} + onChange={onChangeTab} + /> +

총 {alarmList.length}개

+
+ + + {alarmList.map((alarm) => ( + onShowAlarmDetail(alarm.alarmId)}> +

+ {alarm.status} +

+
+
+

{alarm.title}

+ + +
+

+ 발송 일자:{' '} + {alarm.sendAt + ? dayjs(alarm.sendAt).format('YYYY/MM/DD HH:mm') + : ''} +

+
+

{alarm.content}

+
+ { + e.stopPropagation(); + onSendAlarm(alarm.alarmId, alarm.title); + }} + disabled={alarm.status === '발송 후'} + /> +
+
+ toggleDropdown(e, alarm.alarmId)}> + + + {activeDropdownId === alarm.alarmId && ( +
handleDeleteAlarm(e, alarm.alarmId)}> +

삭제하기

+
+ )} +
+
+ ))} +
+ + {showAlarmDetail && ( + + + + )} + + {isSending && } + + ); +} + +export default AlarmList; diff --git a/src/components/alarmAdmin/AlarmList/style.ts b/src/components/alarmAdmin/AlarmList/style.ts new file mode 100644 index 00000000..e0365e89 --- /dev/null +++ b/src/components/alarmAdmin/AlarmList/style.ts @@ -0,0 +1,127 @@ +import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; +import { fonts } from '@sopt-makers/fonts'; + +export const StPageHeader = styled.div` + h1 { + ${fonts.TITLE_32_SB} + color: ${colors.gray10}; + margin-bottom: 41px; + } + p { + ${fonts.TITLE_16_SB} + color: ${colors.gray200}; + margin-top: 55px; + margin-bottom: 18px; + } +`; + +export const StListItem = styled.li` + display: flex; + align-items: center; + padding: 18px 30px 18px 33px; + + .alarm-status { + ${fonts.BODY_14_M} + width: 52px; + height: 48px; + margin-right: 34px; + } + .before { + color: ${colors.error}; + } + .after { + color: ${colors.information}; + } + .alarm-info-wrap { + width: 290px; + margin-right: 36px; + + & > div:first-of-type { + display: flex; + align-items: flex-start; + } + .alarm-title { + ${fonts.TITLE_18_SB} + color: ${colors.gray10}; + max-width: 146px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-right: 15px; + margin-bottom: 4px; + } + .alarm-sent-at { + ${fonts.BODY_14_M} + color: ${colors.gray400}; + } + } + .alarm-content { + ${fonts.BODY_14_M} + color: ${colors.gray100}; + margin-right: 64px; + width: 331px; + height: 48px; + text-overflow: ellipsis; + overflow: hidden; + word-break: break-word; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .alarm-send { + margin-right: 28px; + } + & > div:last-of-type { + display: flex; + flex-direction: column; + position: relative; + .delete_dropdown { + ${fonts.BODY_14_M} + + position: absolute; + top: 100%; + right: 0.001%; // 요소의 왼쪽 경계를 부모의 중앙에 위치시킵니다. + + display: flex; + justify-content: flex-start; + align-items: center; + + width: 9.3rem; + + margin-top: 1rem; + padding: 0.8rem 0.7rem; + + background-color: ${colors.gray700}; + border-radius: 1rem; + + animation: appearDropdown 0.6s; + + & > p { + width: 100%; + height: 100%; + + padding: 0.5rem 0.9rem; + + color: ${colors.error}; + + border-radius: 0.6rem; + + &:hover { + background-color: ${colors.gray600}; + } + } + + @keyframes appearDropdown { + from { + opacity: 0; + transform: translateY(-1rem); + } + to { + opacity: 1; + transform: translateY(0rem); + } + } + } + } +`; diff --git a/src/components/alarmAdmin/CreateAlarmModal/index.tsx b/src/components/alarmAdmin/CreateAlarmModal/index.tsx new file mode 100644 index 00000000..2402a7e7 --- /dev/null +++ b/src/components/alarmAdmin/CreateAlarmModal/index.tsx @@ -0,0 +1,326 @@ +import { useEffect, useState } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { IcDeleteFile, IcUpload } from '@/assets/icons'; +import Button from '@/components/common/Button'; +import DropDown from '@/components/common/DropDown'; +import Input from '@/components/common/Input'; +import Loading from '@/components/common/Loading'; +import ModalFooter from '@/components/common/modal/ModalFooter'; +import ModalHeader from '@/components/common/modal/ModalHeader'; +import OptionTemplate from '@/components/common/OptionTemplate'; +import Selector from '@/components/common/Selector'; +import { currentGenerationState } from '@/recoil/atom'; +import { postNewAlarm } from '@/services/api/alarm'; +import { + readPlaygroundId, + TARGET_GENERATION_LIST, + TARGET_USER_LIST, +} from '@/utils/alarm'; +import { ACTIVITY_GENERATION } from '@/utils/generation'; +import { partList, partTranslator } from '@/utils/session'; + +import { + StAlarmModalWrapper, + StAlarmTypeButton, + StCsvUploader, + StTextArea, +} from './style'; + +interface Props { + onClose: () => void; + alarmId?: number; +} + +function CreateAlarmModal(props: Props) { + const { onClose, alarmId } = props; + + const [selectedValue, setSelectedValue] = useState({ + attribute: 'NOTICE', + part: '발송 파트', + isActive: true, + generationAt: parseInt(ACTIVITY_GENERATION), + targetList: null, + title: '', + content: '', + link: null, + }); + const [dropdownVisibility, setDropdownVisibility] = useState({ + part: false, + target: false, + generation: false, + targetSelector: false, + }); + const [isActiveUser, setIsActiveUser] = useState('활동 회원'); + const [uploadedFile, setUploadedFile] = useState(null); + const [selectedAlarmType, setSelectedAlarmType] = useState({ + notice: true, + news: false, + }); + const [isReadyToSubmit, setIsReadyToSubmit] = useState(true); + const currentGeneration = useRecoilValue(currentGenerationState); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (isActiveUser === '활동 회원') { + setSelectedValue((prev) => ({ ...prev, isActive: true })); + } else { + setSelectedValue((prev) => ({ ...prev, isActive: false })); + } + if (isActiveUser === '특정 유저 지정') { + setSelectedValue((prev) => ({ + ...prev, + part: '발송 파트', + isActive: null, + })); + } + }, [isActiveUser]); + + useEffect(() => { + if ( + (selectedValue.part !== '발송 파트' && + selectedValue.title !== '' && + selectedValue.content !== '') || + (uploadedFile !== null && + isActiveUser === 'CSV 첨부' && + selectedValue.content !== '' && + selectedValue.title !== '') + ) { + setIsReadyToSubmit(false); + } else { + setIsReadyToSubmit(true); + } + }, [ + isActiveUser, + selectedValue.content, + selectedValue.part, + selectedValue.title, + uploadedFile, + ]); + + const handleSubmit = async () => { + setIsSubmitting(true); + + let apiPartValue = selectedValue.part + ? partTranslator[selectedValue.part] + : null; + let apiIsActive = selectedValue.isActive; + let targetListValue = selectedValue.targetList; + + if (isActiveUser === 'CSV 첨부') { + apiPartValue = null; + apiIsActive = null; + } + + if (isActiveUser !== 'CSV 첨부') { + targetListValue = null; + } + + const payload = { + ...selectedValue, + generation: parseInt(currentGeneration), + part: apiPartValue, + isActive: apiIsActive, + targetList: targetListValue, + }; + + await postNewAlarm(payload); + setIsSubmitting(false); + await onClose(); + }; + + const toggleDropdown = (type: AlarmDropdownType) => { + setDropdownVisibility((prev) => ({ ...prev, [type]: !prev[type] })); + }; + + const handleCSVUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + try { + const userIds = await readPlaygroundId(file); + setUploadedFile(file); + setSelectedValue((prev) => ({ ...prev, targetList: userIds })); + } catch (error) { + console.error('파일을 읽는데 실패했습니다.', error); + } + } + }; + + const handleAlarmType = (type: string): void => { + if (type === 'NOTICE') { + setSelectedValue((prev) => ({ + ...prev, + attribute: 'NOTICE', + })); + setSelectedAlarmType({ notice: true, news: false }); + } + if (type === 'NEWS') { + setSelectedValue((prev) => ({ + ...prev, + attribute: 'NEWS', + })); + setSelectedAlarmType({ notice: false, news: true }); + } + }; + + if (isSubmitting) return ; + return ( + + +
+
+ handleAlarmType('NOTICE')} + isSelected={selectedAlarmType.notice}> + 공지 + + handleAlarmType('NEWS')} + isSelected={selectedAlarmType.news}> + 소식 + +
+
+ + toggleDropdown('target')} + isDisabledValue={false} + /> + {dropdownVisibility.target && ( + { + setIsActiveUser(value); + toggleDropdown('target'); + }} + /> + )} + + {isActiveUser !== 'CSV 첨부' && ( + <> + + toggleDropdown('part')} + isDisabledValue={selectedValue.part === '발송 파트'} + /> + {dropdownVisibility.part && ( + { + setSelectedValue((prev) => ({ + ...prev, + part: value, + })); + toggleDropdown('part'); + }} + /> + )} + + + toggleDropdown('generation')} + isDisabledValue={selectedValue.isActive == true} + /> + {dropdownVisibility.generation && !selectedValue.isActive && ( + !item.includes(ACTIVITY_GENERATION), + )} + onItemSelected={(value) => { + setSelectedValue((prev) => ({ + ...prev, + generation: parseInt(value), + })); + toggleDropdown('generation'); + }} + /> + )} + + + )} +
+
+ {isActiveUser === 'CSV 첨부' && ( + + + {uploadedFile ? ( +
+ {uploadedFile.name} + setUploadedFile(null)} /> +
+ ) : ( +
+ document.getElementById('csvUploaderInput')?.click() + }> + + + 눌러서 첨부하기 +
+ )} +
+
+ )} + + { + setSelectedValue((prev) => ({ + ...prev, + title: e.target.value, + })); + }} + /> + + + { + setSelectedValue((prev) => ({ + ...prev, + content: e.target.value, + })); + }} + /> + + + + +
+
+ + +

{member.name}

+

{member.score}점

+

{member.part}

+

{member.university}

+

{member.phone}

-
- - - - {HEADER_LABELS.map((label, index) => ( - - {label} - - ))} - - - -
-
+
- - {member.lectures.map((lecture, index) => { - const firstRound = - lecture.attendances.find((item) => item.round === 1) ?? - scoreDetailAttendanceInit; - const secondRound = - lecture.attendances.find((item) => item.round === 2) ?? - scoreDetailAttendanceInit; - const firstRoundDate = dayjs(firstRound.date).format( - 'YYYY/MM/DD HH:mm', - ); - const secondRoundDate = dayjs(secondRound.date).format( - 'YYYY/MM/DD HH:mm', - ); - return ( - - - {precision(index + 1, 2)} - - + {member.lectures.map((lecture) => { + const firstRound = + lecture.attendances.find((item) => item.round === 1) ?? + scoreDetailAttendanceInit; + const secondRound = + lecture.attendances.find((item) => item.round === 2) ?? + scoreDetailAttendanceInit; + const firstRoundDate = dayjs(firstRound.date).format( + 'YYYY/MM/DD HH:mm', + ); + const secondRoundDate = dayjs(secondRound.date).format( + 'YYYY/MM/DD HH:mm', + ); + return ( + +
+
{lecture.lecture} - - - {firstRound.status} - - {firstRoundDate} - - {secondRound.status} - - {secondRoundDate} - - {lecture.status} - - - {lecture.additiveScore}점 - - - ); - })} - + + +
+

+ 2023년 00월 00일 14:00 - 18:00 +

+
+
+

+ + 1차 {firstRound.status} + + {firstRoundDate} +

+

+ + 2차 {secondRound.status} + + {secondRoundDate} +

+
+
+ ); + })}
+ ); diff --git a/src/components/attendanceAdmin/totalScore/MemberDetail/style.ts b/src/components/attendanceAdmin/totalScore/MemberDetail/style.ts index af8e2474..6f540736 100644 --- a/src/components/attendanceAdmin/totalScore/MemberDetail/style.ts +++ b/src/components/attendanceAdmin/totalScore/MemberDetail/style.ts @@ -1,51 +1,43 @@ import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; +import { fonts } from '@sopt-makers/fonts'; export const StModalWrap = styled.div` - padding: 0 4rem 4rem 4rem; - button.close-btn { - position: absolute; - top: 0; - right: 0; - width: 4.4rem; - height: 4.4rem; - background: none; - margin: 3.2rem 3.2rem 0 0; - } + padding: 41px 34px 30px 34px; + display: flex; + align-items: flex-start; + header { - margin-top: 3.2rem; - display: flex; - flex-direction: column; - gap: 0.8rem; - margin-bottom: 4rem; - p { - font-size: 1.6rem; - line-height: 2.2rem; - color: ${({ theme }) => theme.color.grayscale.gray80}; - } .member-name { - font-size: 2.4rem; - font-weight: 700; - line-height: 3.4rem; - color: ${({ theme }) => theme.color.grayscale.black80}; - span { - color: ${({ theme }) => theme.color.main.orange50}; - margin-left: 0.8rem; - } + ${fonts.TITLE_28_SB} + color: ${colors.gray10}; + } + .member-score { + ${fonts.TITLE_28_SB} + color: ${colors.orange400}; + margin-bottom: 16px; + } + .chip { + ${fonts.BODY_14_M} + color: ${colors.gray100}; + background-color: ${colors.gray700}; + padding: 5px 8px; + border-radius: 4px; + margin-bottom: 7px; + width: fit-content; } } - .list-head > table { - border-spacing: 0; - } - .list-body { - width: calc(100% + 8px); - height: 36rem; + .score-list { + width: 654px; + height: 510px; overflow-y: scroll; - padding-right: 5px; + margin: 0 27px 0 40px; + &::-webkit-scrollbar { width: 3px; } &::-webkit-scrollbar-thumb { - background: ${({ theme }) => theme.color.grayscale.gray30}; + background: ${colors.gray500}; border-radius: 3px; } &::-webkit-scrollbar-track { @@ -55,9 +47,49 @@ export const StModalWrap = styled.div` `; export const StSessionName = styled.p` - max-width: 15rem; + ${fonts.TITLE_18_SB} + color: ${colors.gray10}; + max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - margin: 0 auto; + margin-right: 12px; +`; + +export const StListItem = styled.li` + pointer-events: none; + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 33px; + border: 1px solid ${colors.gray700} !important; + + .session-score { + display: flex; + align-items: center; + margin-bottom: 4px; + } + .session-date { + ${fonts.BODY_14_M} + color: ${colors.gray300}; + } + .attendance-info { + ${fonts.BODY_14_M} + color: ${colors.gray300}; + display: flex; + flex-direction: column; + gap: 5px; + + .attendance { + color: ${colors.gray100}; + margin-right: 15px; + } + .absent { + color: ${colors.error}; + } + span:not(.attendance) { + display: inline-block; + width: 122px; + } + } `; diff --git a/src/components/attendanceAdmin/totalScore/MemberList/index.tsx b/src/components/attendanceAdmin/totalScore/MemberList/index.tsx index 52d4581c..ff29aa8a 100644 --- a/src/components/attendanceAdmin/totalScore/MemberList/index.tsx +++ b/src/components/attendanceAdmin/totalScore/MemberList/index.tsx @@ -1,7 +1,7 @@ import { RefObject, useRef, useState } from 'react'; import { useRecoilValue } from 'recoil'; -import ListActionButton from '@/components/common/ListActionButton'; +import Chip from '@/components/common/Chip'; import ListWrapper from '@/components/common/ListWrapper'; import Loading from '@/components/common/Loading'; import PartFilter from '@/components/common/PartFilter'; @@ -13,35 +13,9 @@ import { precision } from '@/utils'; import { getPartValue, partTranslator } from '@/utils/session'; import MemberDetail from '../MemberDetail'; -import { StListHeader, StMemberName, StMemberUniversity } from './style'; +import { StListItem, StPageHeader } from './style'; function MemberList() { - const HEADER_LABELS = [ - '순번', - '회원명', - '학교명', - '파트명', - '총점', - '출석', - '지각', - '결석', - '참여', - '관리', - ]; - - const TABLE_WIDTH = [ - '10%', - '13.5%', - '13.5%', - '12%', - '12%', - '5%', - '5%', - '5%', - '5%', - '12%', - ]; - const [selectedPart, setSelectedPart] = useState('ALL'); const [selectedMember, setSelectedMember] = useState( null, @@ -65,7 +39,7 @@ function MemberList() { setSelectedPart(part); }; - const onChangeMember = (member: ScoreMember) => { + const onShowMemberDetail = (member: ScoreMember) => { setSelectedMember(member); }; @@ -75,53 +49,64 @@ function MemberList() { return ( <> - +

출석 총점

-
- - - - {HEADER_LABELS.map((label) => ( - {label} - ))} - - - - {members?.pages.map( - (pageMembers, pageIndex) => - pageMembers && - pageMembers.map((member, index) => { - const { part, name, university, score, total } = member; - const { attendance, tardy, absent, participate } = total; - const partName = getPartValue(partTranslator, part) || part; - - return ( - - {precision(pageIndex * PAGE_SIZE + index + 1, 2)} - - {name} - - - {university} - - {partName} - {score} - {attendance} - {tardy} - {absent} - {participate} - - onChangeMember(member)} - /> - - - ); - }), - )} - +

총 0명

+ + + {members?.pages.map( + (pageMembers, pageIndex) => + pageMembers && + pageMembers.map((member, index) => { + const { part, name, university, score, total } = member; + const { attendance, tardy, absent, participate } = total; + const partName = getPartValue(partTranslator, part) || part; + + return ( + onShowMemberDetail(member)}> +
+

+ {precision(pageIndex * PAGE_SIZE + index + 1, 2)} +

+
+
+

{name}

+ +
+

{university}

+
+
+
+

+ 출석 + {attendance} +

+

+ 지각 + {tardy} +

+

+ 결석 + {absent} +

+

+ 미정 + {participate} +

+

+ {score}점 +

+
+
+ ); + }), + )}
diff --git a/src/components/attendanceAdmin/totalScore/MemberList/style.ts b/src/components/attendanceAdmin/totalScore/MemberList/style.ts index 0fee02ab..21b15fb2 100644 --- a/src/components/attendanceAdmin/totalScore/MemberList/style.ts +++ b/src/components/attendanceAdmin/totalScore/MemberList/style.ts @@ -1,31 +1,74 @@ import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; +import { fonts } from '@sopt-makers/fonts'; -export const StListHeader = styled.header` +export const StPageHeader = styled.header` + h1 { + ${fonts.TITLE_32_SB} + color: ${colors.gray10}; + margin-bottom: 41px; + } + p { + ${fonts.TITLE_16_SB} + color: ${colors.gray200}; + margin: 56px 0 18px 12px; + } +`; + +export const StListItem = styled.li` + color: ${colors.gray100}; display: flex; justify-content: space-between; - margin-bottom: 5rem; + padding: 18px 43px 18px 33px; - & > h1 { - font-weight: 600; - font-size: 20px; - line-height: 25px; - letter-spacing: -0.02em; - color: ${({ theme }) => theme.color.grayscale.black60}; + .member-info-wrap { + display: flex; + + .index { + ${fonts.BODY_14_M} + width: 26px; + margin-right: 33px; + } + .member-info > div:first-of-type { + display: flex; + align-items: center; + gap: 15px; + } + .member-name { + ${fonts.TITLE_18_SB} + color: ${colors.gray30}; + margin-bottom: 4px; + } + .member-university { + ${fonts.BODY_14_M} + color: ${colors.gray400}; + } } -`; + .member-score-wrap { + display: flex; + align-items: center; -export const StMemberName = styled.p` - max-width: 7.3rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin: 0 auto; -`; + .attendance { + ${fonts.BODY_16_M} + color: ${colors.gray100}; + margin-right: 38px; -export const StMemberUniversity = styled.p` - max-width: 9.5rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin: 0 auto; + span { + color: ${colors.gray500}; + margin-right: 10px; + } + } + .member-score { + ${fonts.BODY_16_M} + color: ${colors.gray50}; + background-color: ${colors.gray700}; + padding: 5px 13px; + border-radius: 30px; + width: fit-content; + margin: 0 auto; + } + .minus-score { + color: ${colors.error}; + } + } `; diff --git a/src/components/common/AttendanceChip/index.tsx b/src/components/common/AttendanceChip/index.tsx new file mode 100644 index 00000000..e5f2bfa1 --- /dev/null +++ b/src/components/common/AttendanceChip/index.tsx @@ -0,0 +1,13 @@ +import { StAttendanceChip } from './style'; + +interface Props { + text: string; +} + +function AttendanceChip(props: Props) { + const { text } = props; + + return {text}; +} + +export default AttendanceChip; diff --git a/src/components/common/AttendanceChip/style.ts b/src/components/common/AttendanceChip/style.ts new file mode 100644 index 00000000..95f5f228 --- /dev/null +++ b/src/components/common/AttendanceChip/style.ts @@ -0,0 +1,55 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; +import { fonts } from '@sopt-makers/fonts'; + +export const IndicatorStructure = styled.span` + ${fonts.BODY_14_M} + display: inline-block; + color: ${colors.gray10}; + border-radius: 8px; + padding: 2px 11px; + margin-right: 6px; +`; + +export const StAttendanceChip = styled(IndicatorStructure)<{ text: string }>` + ${({ text }) => getChipColor(text)} +`; + +const getChipColor = (text: string) => { + switch (text) { + case '출석': + case '참여': + return css` + background-color: ${colors.green900}; + color: ${colors.information}; + `; + case '결석': + return css` + background-color: ${colors.red800}; + color: ${colors.red300}; + `; + case '지각': + return css` + background-color: ${colors.yellow900}; + color: ${colors.attention}; + `; + case '미참여': + return css` + background-color: ${colors.gray600}; + color: ${colors.gray200}; + `; + default: + if (text.includes('-')) { + return css` + background-color: ${colors.red800}; + color: ${colors.red300}; + `; + } + return css` + background-color: ${colors.gray600}; + color: ${colors.gray10}; + padding: 2px 8px; + `; + } +}; diff --git a/src/components/common/Button/style.ts b/src/components/common/Button/style.ts index c20f32a6..f008717a 100644 --- a/src/components/common/Button/style.ts +++ b/src/components/common/Button/style.ts @@ -1,5 +1,6 @@ import { css } from '@emotion/react'; import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; import { Props } from './index'; @@ -9,28 +10,29 @@ export const StButton = styled.button>` font-size: 1.6rem; line-height: 1; font-weight: 600; - border-radius: 4rem; + padding: 1.6rem 2.4rem; + border-radius: 1rem; &:disabled { - background-color: ${({ theme }) => theme.color.grayscale.gray30}; - border: 1px solid ${({ theme }) => theme.color.grayscale.gray30}; - color: ${({ theme }) => theme.color.grayscale.white100}; + background-color: ${colors.gray600}; + color: ${colors.gray400}; cursor: default; } + &:hover { + background-color: ${colors.gray600}; + } ${({ theme, type }) => type === 'button' ? css` - background-color: ${theme.color.grayscale.white100}; - border: 1px solid ${theme.color.grayscale.gray30}; - color: ${theme.color.grayscale.gray60}; + background: none; + color: ${colors.gray200}; ` : type === 'submit' ? css` - background-color: ${theme.color.grayscale.black100}; - border: 1px solid ${theme.color.grayscale.black100}; - color: ${theme.color.grayscale.white100}; + background-color: ${colors.white}; + color: ${colors.black}; ` : css` - background-color: ${theme.color.grayscale.gray30}; + background: none; color: ${theme.color.grayscale.white100}; `}; `; diff --git a/src/components/common/Chip/index.tsx b/src/components/common/Chip/index.tsx new file mode 100644 index 00000000..4cf5564f --- /dev/null +++ b/src/components/common/Chip/index.tsx @@ -0,0 +1,13 @@ +import { StChip } from './style'; + +interface Props { + text: string; +} + +function Chip(props: Props) { + const { text } = props; + + return {text}; +} + +export default Chip; diff --git a/src/components/common/Chip/style.ts b/src/components/common/Chip/style.ts new file mode 100644 index 00000000..d9c5399f --- /dev/null +++ b/src/components/common/Chip/style.ts @@ -0,0 +1,65 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; +import { fonts } from '@sopt-makers/fonts'; + +export const IndicatorStructure = styled.span` + ${fonts.LABEL_12_SB} + display: inline-block; + color: ${colors.gray200}; + border: 1px solid ${colors.gray500}; + border-radius: 20px; + padding: 3.5px 8px; + margin-right: 10px; +`; + +export const StSessionIndicator = styled(IndicatorStructure)<{ + attributeName: string; +}>` + ${({ attributeName }) => + attributeName === '세미나' + ? css` + border-color: ${colors.orange600}; + color: ${colors.orange600}; + ` + : attributeName === '행사' + ? css` + border-color: ${colors.blue400}; + color: ${colors.blue400}; + ` + : css` + border-color: ${colors.yellow700}; + color: ${colors.yellow700}; + `} +`; + +export const StChip = styled(IndicatorStructure)<{ text: string }>` + ${({ text }) => getChipColor(text)} +`; + +const getChipColor = (text: string) => { + switch (text) { + case '세미나': + case '공지': + return css` + border-color: ${colors.orange600}; + color: ${colors.orange600}; + `; + case '행사': + case '소식': + return css` + border-color: ${colors.blue400}; + color: ${colors.blue400}; + `; + case '기타': + return css` + border-color: ${colors.yellow700}; + color: ${colors.yellow700}; + `; + default: + return css` + border-color: ${colors.gray500}; + color: ${colors.gray200}; + `; + } +}; diff --git a/src/components/common/DropDown/style.ts b/src/components/common/DropDown/style.ts index c7ef8b06..f26ab526 100644 --- a/src/components/common/DropDown/style.ts +++ b/src/components/common/DropDown/style.ts @@ -1,26 +1,24 @@ import { css } from '@emotion/react'; import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; import { Props } from './index'; export const DropdownWrapper = styled.div>` position: absolute; - width: 10rem; + top: 100%; + + width: 100%; + min-width: 11rem; height: auto; - max-height: 32.1rem; + margin-top: 1rem; + padding: 0.8rem 0.7rem; - box-shadow: 0px 10px 20px rgba(0, 0, 0, 0.1); + box-shadow: 0px 5px 20px 0px rgba(63, 64, 66, 0.15); border-radius: 1.3rem; - ${({ type }) => - type === 'times' && - css` - margin-left: 9.3rem; - overflow: scroll; - `} - - background: ${({ theme }) => theme.color.grayscale.white100}; + background-color: ${colors.gray500}; z-index: 1; @@ -28,23 +26,41 @@ export const DropdownWrapper = styled.div>` & > div { display: flex; flex-direction: column; - gap: 1.5rem; + gap: 0.4rem; + + max-height: auto; + + ${({ type }) => + type === 'times' && + css` + max-height: 20.2rem; + overflow-y: scroll; // 세로 스크롤만 허용 + overflow-x: hidden; // 가로 스크롤 숨기기 + + ::-webkit-scrollbar { + width: 0.6rem; + } - padding: 0.7rem; + ::-webkit-scrollbar-thumb { + background-color: ${colors.gray300}; + border-radius: 0.8rem; + } + `} & > p { - padding: 0.75rem 0 0.75rem 0.5rem; + padding: 0.5rem 0.9rem; - color: ${({ theme }) => theme.color.grayscale.black60}; + color: ${colors.gray10}; + font-size: 1.6rem; + font-style: normal; font-weight: 500; - font-size: 16px; - line-height: 100%; - letter-spacing: -0.01em; + line-height: 100%; /* 1.6rem */ + letter-spacing: -0.016rem; border-radius: 0.6rem; &:hover { - background-color: ${({ theme }) => theme.color.grayscale.gray20}; + background-color: ${colors.gray400}; cursor: pointer; } diff --git a/src/components/common/FilterButton/index.tsx b/src/components/common/FilterButton/index.tsx index dfe47e90..07fe3506 100644 --- a/src/components/common/FilterButton/index.tsx +++ b/src/components/common/FilterButton/index.tsx @@ -1,11 +1,12 @@ import { FilterButtonItem, - StWrapper, + FilterWrapper, + StUnderline, } from '@/components/common/FilterButton/style'; interface Props { list: T[]; - translator: Record; + translator?: Record; onChange: (item: T) => void; selected: T; } @@ -13,16 +14,19 @@ interface Props { function FilterButton(props: Props) { const { list, translator, selected, onChange } = props; return ( - - {list.map((item: T) => ( - onChange(item)}> - {translator[item]} - - ))} - + <> + + {list.map((item: T) => ( + onChange(item)}> + {translator ? translator[item] : item} + + ))} + + + ); } diff --git a/src/components/common/FilterButton/style.ts b/src/components/common/FilterButton/style.ts index 331a8ec5..f7f42832 100644 --- a/src/components/common/FilterButton/style.ts +++ b/src/components/common/FilterButton/style.ts @@ -1,29 +1,36 @@ import { css } from '@emotion/react'; import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; +import { fonts } from '@sopt-makers/fonts'; -export const StWrapper = styled.div` - padding: 0.8rem 1rem; - background-color: ${({ theme }) => theme.color.grayscale.gray10}; - border-radius: 4rem; - width: fit-content; - box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.02); +export const FilterWrapper = styled.div` + display: flex; + gap: 26px; +`; +export const StUnderline = styled.div` + width: calc(100vw - 212px); + margin-left: calc((100vw - 212px - 100%) / -2); + height: 1px; + background-color: ${colors.gray800}; `; export const FilterButtonItem = styled.button<{ selected: boolean }>` - font-size: 1.4rem; - line-height: 1.7rem; - padding: 1.1rem 3.6rem; - border-radius: 4rem; + ${fonts.TITLE_20_SB} + + padding-bottom: 13px; transition: all 0.2s; - ${({ theme, selected }) => + + ${({ selected }) => selected ? css` - font-weight: 600; - color: ${theme.color.grayscale.gray10}; - background-color: ${theme.color.grayscale.black60}; + color: ${colors.gray30}; + border-bottom: 3px solid ${colors.gray30}; ` : css` - font-weight: 400; - color: ${theme.color.grayscale.gray80}; - background-color: ${theme.color.grayscale.gray10}; + color: ${colors.gray400}; + border-bottom: 3px solid transparent; `} + + &:hover { + color: ${colors.gray100}; + } `; diff --git a/src/components/common/FloatingButton/index.tsx b/src/components/common/FloatingButton/index.tsx new file mode 100644 index 00000000..052edc77 --- /dev/null +++ b/src/components/common/FloatingButton/index.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from 'react'; + +import { StFloatingButton } from './style'; + +interface Props { + content: ReactNode; + onClick: () => void; +} + +function FloatingButton(props: Props) { + const { content, onClick } = props; + + return {content}; +} + +export default FloatingButton; diff --git a/src/components/common/FloatingButton/style.ts b/src/components/common/FloatingButton/style.ts new file mode 100644 index 00000000..63492c21 --- /dev/null +++ b/src/components/common/FloatingButton/style.ts @@ -0,0 +1,17 @@ +import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; +import { fonts } from '@sopt-makers/fonts'; + +import zIndex from '@/utils/zIndex'; + +export const StFloatingButton = styled.button` + position: fixed; + right: 60px; + bottom: 60px; + padding: 14px 30px; + border-radius: 60px; + z-index: ${zIndex.footer}; + background-color: ${colors.gray10}; + color: ${colors.gray900}; + ${fonts.TITLE_20_SB} +`; diff --git a/src/components/common/Header/index.tsx b/src/components/common/Header/index.tsx index b4aa74bb..11b76bc9 100644 --- a/src/components/common/Header/index.tsx +++ b/src/components/common/Header/index.tsx @@ -1,6 +1,6 @@ import { useRouter } from 'next/router'; -import { IcGoPrev } from '@/assets/icons'; +import AdminStatusDevtools from '@/components/devTools/AdminStatus'; import { destroyToken } from '@/utils/auth'; import { StHeader } from './style'; @@ -15,11 +15,13 @@ function Header() { return ( - - diff --git a/src/components/common/Header/style.ts b/src/components/common/Header/style.ts index dbf0f016..ff8826cf 100644 --- a/src/components/common/Header/style.ts +++ b/src/components/common/Header/style.ts @@ -1,10 +1,11 @@ import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; import zIndex from '@/utils/zIndex'; export const StHeader = styled.header` display: flex; - justify-content: space-between; + justify-content: flex-end; align-items: center; position: fixed; @@ -12,44 +13,41 @@ export const StHeader = styled.header` top: 0; left: 22rem; width: calc(100% - 22rem); - height: 6rem; + height: 8rem; padding: 0 2.6rem; - background-color: ${({ theme }) => theme.color.grayscale.gray10}; + div.status_devtools { + width: fit-content; + padding: 2rem; + } button { - display: flex; - align-items: center; - gap: 0.9rem; - background: none; + width: 10.2rem; + padding: 0.3rem 0.6rem; + + color: ${colors.gray200}; + background-color: ${colors.gray800}; + + border-radius: 1.9rem; cursor: pointer; - & > p { - font-weight: 400; - font-size: 1.6rem; - line-height: 100%; - letter-spacing: -0.02em; - color: ${({ theme }) => theme.color.grayscale.black40}; + &:hover { + color: ${colors.gray10}; + background-color: ${colors.gray700}; + } + &:active { + background-color: ${colors.gray600}; } - &.logout > p { - padding: 1.1rem 2.3rem; - font-weight: 600; - font-size: 12px; - line-height: 100%; + & > p { text-align: center; - letter-spacing: -0.02em; - - background: ${({ theme }) => theme.color.grayscale.white100}; - color: ${({ theme }) => theme.color.grayscale.gray60}; - border: 0.1rem solid ${({ theme }) => theme.color.grayscale.gray20}; - border-radius: 4rem; - } - &.logout > p:hover { - color: ${({ theme }) => theme.color.grayscale.black40}; - background: ${({ theme }) => theme.color.grayscale.gray20}; - border: 0.1rem solid ${({ theme }) => theme.color.grayscale.gray60}; + font-family: SUIT; + font-size: 1.6rem; + font-style: normal; + font-weight: 600; + line-height: 150%; /* 2.4rem */ + letter-spacing: -0.024rem; } } `; diff --git a/src/components/common/Input/index.tsx b/src/components/common/Input/index.tsx new file mode 100644 index 00000000..a60eb393 --- /dev/null +++ b/src/components/common/Input/index.tsx @@ -0,0 +1,27 @@ +import { ChangeEvent, ChangeEventHandler } from 'react'; + +import { StInput } from './style'; + +interface Props { + type: string; + placeholder?: string; + value?: string; + readOnly?: boolean; + onChange?: (event: ChangeEvent) => void; +} + +function Input(props: Props) { + const { type, placeholder, value, readOnly = false, onChange } = props; + + return ( + + ); +} + +export default Input; diff --git a/src/components/common/Input/style.ts b/src/components/common/Input/style.ts new file mode 100644 index 00000000..bdb43b72 --- /dev/null +++ b/src/components/common/Input/style.ts @@ -0,0 +1,28 @@ +import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; +import { fonts } from '@sopt-makers/fonts'; + +export const StInput = styled.input` + padding: 1rem 1.4rem; + + ${fonts.LABEL_18_SB} + + color: ${colors.gray10}; + background-color: ${colors.gray700}; + border: none; + outline: none; + + border-radius: 0.8rem; + + &::placeholder { + color: ${colors.gray400}; + } + + &:not(:read-only):focus { + background-color: ${colors.gray600}; + outline: 0.1rem solid ${colors.gray300}; + } + &:read-only { + cursor: default; + } +`; diff --git a/src/components/common/Layout/style.ts b/src/components/common/Layout/style.ts index aa3c8299..4dfea1f0 100644 --- a/src/components/common/Layout/style.ts +++ b/src/components/common/Layout/style.ts @@ -5,7 +5,7 @@ export const StLayout = styled.div` display: flex; .main-wrapper { width: 100%; - padding: 10rem 0 0 22rem; + padding: 88px 0 0 212px; main { max-width: 98rem; width: 100%; diff --git a/src/components/common/ListActionButton/style.ts b/src/components/common/ListActionButton/style.ts index 62a3d9a1..45f08b2f 100644 --- a/src/components/common/ListActionButton/style.ts +++ b/src/components/common/ListActionButton/style.ts @@ -1,27 +1,27 @@ import styled from '@emotion/styled'; - -import { body2 } from '@/styles/fonts'; +import { colors } from '@sopt-makers/colors'; +import { fonts } from '@sopt-makers/fonts'; export const StButton = styled.button` - ${body2} + ${fonts.BODY_14_M} + color: ${colors.gray10}; transition: transform 0.1s; + display: inline-block; - padding: 0.6rem 1rem; - border: 0.1rem solid ${({ theme }) => theme.color.grayscale.gray60}; - border-radius: 1.6rem; - background-color: ${({ theme }) => theme.color.grayscale.gray20}; - color: ${({ theme }) => theme.color.grayscale.black40}; + padding: 6px 10px; + border: 1px solid ${colors.gray300}; + border-radius: 8px; + background-color: ${colors.gray600}; + cursor: pointer; &:disabled { - background-color: ${({ theme }) => theme.color.grayscale.gray10}; - border: 1px solid ${({ theme }) => theme.color.grayscale.gray20}; - color: ${({ theme }) => theme.color.grayscale.gray30}; + color: ${colors.gray500}; + background-color: ${colors.gray800}; + border-color: ${colors.gray800}; cursor: default; } - cursor: pointer; &:not(:disabled):hover { transform: scale(1.15); - border-color: ${({ theme }) => theme.color.grayscale.gray100}; } `; diff --git a/src/components/common/ListWrapper/index.tsx b/src/components/common/ListWrapper/index.tsx index 91a931cf..b4f71182 100644 --- a/src/components/common/ListWrapper/index.tsx +++ b/src/components/common/ListWrapper/index.tsx @@ -3,19 +3,13 @@ import { ReactNode } from 'react'; import { StList } from './style'; interface Props { - tableWidth?: string[]; // % 단위 children: ReactNode; } -/** - * 리스트에 스타일 입혀주는 Wrapper Component - * @param children thead는 th를 tr로 감싸고, tbody는 td를 tr로 감싸야함 - * @returns 리스트에 스타일을 입혀서 반환 - */ function ListWrapper(props: Props) { - const { children, tableWidth = [] } = props; + const { children } = props; - return {children}; + return {children}; } export default ListWrapper; diff --git a/src/components/common/ListWrapper/style.ts b/src/components/common/ListWrapper/style.ts index 78db8b89..75b95bc0 100644 --- a/src/components/common/ListWrapper/style.ts +++ b/src/components/common/ListWrapper/style.ts @@ -1,58 +1,31 @@ import styled from '@emotion/styled'; -import css from 'styled-jsx/css'; +import { colors } from '@sopt-makers/colors'; +import { fonts } from '@sopt-makers/fonts'; -import { body2, caption1 } from '@/styles/fonts'; - -export const StList = styled.table<{ tableWidth: string[] }>` +export const StList = styled.ul` width: 100%; - border-collapse: separate; - border-spacing: 0 1.6rem; - thead > tr { - ${caption1} - font-weight: 400; - color: ${({ theme }) => theme.color.grayscale.black40}; - opacity: 0.7; - & > th { - padding-bottom: 2.4rem; - } - ${({ tableWidth }) => - tableWidth && - tableWidth - .map( - (width, index) => `th:nth-of-type(${index + 1}) { width: ${width} }`, - ) - .join('')} - } - tbody > tr { - ${body2} - height: 7rem; - color: ${({ theme }) => theme.color.grayscale.black40}; - & > td { - height: inherit; - text-align: center; - vertical-align: middle; - background-color: ${({ theme }) => theme.color.grayscale.white100}; - border-top: 0.5px solid ${({ theme }) => theme.color.grayscale.gray30}; - border-bottom: 0.5px solid ${({ theme }) => theme.color.grayscale.gray30}; - } + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 120px; - & > td:first-of-type { - border-top-left-radius: 1rem; - border-bottom-left-radius: 1rem; - border: 0.5px solid ${({ theme }) => theme.color.grayscale.gray30}; - border-right: none; + & > li { + border-radius: 10px; + border: 1px solid ${colors.gray800}; + color: ${colors.gray300}; + + &:not(.no-pointer):hover { + border: 1px solid ${colors.gray600}; + background-color: ${colors.gray900}; + cursor: pointer; } - & > td:last-of-type { - border-top-right-radius: 1rem; - border-bottom-right-radius: 1rem; - border: 0.5px solid ${({ theme }) => theme.color.grayscale.gray30}; - border-left: none; + &.focused { + border: 1px solid ${colors.gray600}; + background-color: ${colors.gray900}; + + &:hover { + cursor: default; + } } } - .focused > td, - .focused > td:first-of-type, - .focused > td:last-of-type { - border-color: ${({ theme }) => theme.color.grayscale.black40}; - border-width: 1px; - } `; diff --git a/src/components/common/Nav/GenerationDropDown/index.tsx b/src/components/common/Nav/GenerationDropDown/index.tsx new file mode 100644 index 00000000..30bf7b03 --- /dev/null +++ b/src/components/common/Nav/GenerationDropDown/index.tsx @@ -0,0 +1,80 @@ +import { EmotionJSX } from '@emotion/react/types/jsx-namespace'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; + +import { IcNewDropdown } from '@/assets/icons'; +import { DoSoptLogo } from '@/assets/icons/SoptLogos'; +import { useRecoilGenerationSSR } from '@/hooks/useRecoilGenerationSSR'; +import { GENERATION_INFO } from '@/utils/nav'; + +import { + StDropdownGeneration, + StGenerationDropdown, + StSelectedGeneration, + StWrapper, +} from './style'; + +function GenerationDropDown() { + const router = useRouter(); + + const [currentGeneration, setCurrentGeneration] = useRecoilGenerationSSR(); + const [isDropdownOn, setIsDropdownOn] = useState(false); + + const [selectedGenerationInfo, setSelectedGenerationInfo] = useState({ + logo: , + slogan: 'DO', + }); + + const handleSelectedGeneration = ( + selectedGeneration: string, + logo: EmotionJSX.Element, + slogan: string, + ) => { + setCurrentGeneration(selectedGeneration); + setSelectedGenerationInfo({ logo: logo, slogan: slogan }); + setIsDropdownOn(false); + const pathSegments = router.asPath.split('/'); + if (pathSegments[pathSegments.length - 1].match(/^\d+$/)) { + router.push('/attendanceAdmin/session'); + } + }; + + return ( + + setIsDropdownOn(!isDropdownOn)}> + {selectedGenerationInfo.logo} +
+

+ {currentGeneration}기 +

+

{selectedGenerationInfo.slogan} SOPT

+
+
+ {isDropdownOn && ( + +
+ {GENERATION_INFO.map((info) => { + const { generation, Logo, slogan } = info; + + return ( + + handleSelectedGeneration(generation, , slogan) + }> + +
+

{generation}기

+

{slogan} SOPT

+
+
+ ); + })} +
+
+ )} +
+ ); +} + +export default GenerationDropDown; diff --git a/src/components/common/Nav/GenerationDropDown/style.ts b/src/components/common/Nav/GenerationDropDown/style.ts new file mode 100644 index 00000000..5c3a6a38 --- /dev/null +++ b/src/components/common/Nav/GenerationDropDown/style.ts @@ -0,0 +1,88 @@ +import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; +import { fonts } from '@sopt-makers/fonts'; + +export const StWrapper = styled.div` + padding: 0 1.8rem 3.3rem 1.8rem; +`; + +const GenerationContainer = styled.div` + display: flex; + gap: 1.4rem; + padding: 1rem 0 1rem 1.2rem; + border-radius: 1rem; + + cursor: pointer; + + & > div { + display: flex; + flex-direction: column; + gap: 0.1rem; + + & > h1 { + display: flex; + align-items: center; + gap: 0.6rem; + + ${fonts.HEADING_16_B} + color: ${colors.gray10}; + } + & > h2 { + ${fonts.LABEL_12_SB} + color: ${colors.gray300}; + } + } +`; + +export const StSelectedGeneration = styled(GenerationContainer)` + &:hover { + background-color: ${colors.gray800}; + } + &:active { + background-color: ${colors.gray700}; + } +`; + +export const StDropdownGeneration = styled(GenerationContainer)` + &:hover { + background-color: ${colors.gray600}; + } + &:active { + background-color: ${colors.gray500}; + } +`; + +export const StGenerationDropdown = styled.div` + position: absolute; + + margin-top: 1rem; + + width: 17.6rem; + height: auto; + + background-color: ${colors.gray700}; + + border-radius: 1.3rem; + box-shadow: 0px 5px 20px 0px rgba(63, 64, 66, 0.15); + + animation: appearDropdown 0.6s; + + @keyframes appearDropdown { + from { + opacity: 0; + transform: translateY(-1rem); + } + to { + opacity: 1; + transform: translateY(0rem); + } + } + + & > div { + display: flex; + flex-direction: column; + gap: 0.6rem; + + padding: 0.8rem 0.7rem; + } +`; diff --git a/src/components/common/Nav/index.tsx b/src/components/common/Nav/index.tsx index 396c6e07..a529dbbe 100644 --- a/src/components/common/Nav/index.tsx +++ b/src/components/common/Nav/index.tsx @@ -1,77 +1,33 @@ import { useRouter } from 'next/router'; -import React, { useEffect, useRef, useState } from 'react'; +import { Fragment, useContext } from 'react'; -import { IcNavMenu } from '@/assets/icons'; -import { useRecoilGenerationSSR } from '@/hooks/useRecoilGenerationSSR'; -import { GENERATION_LIST } from '@/utils/generation'; +import { adminStatusContext } from '@/components/devTools/AdminContextProvider'; import { MENU_LIST } from '@/utils/nav'; -import DropDown from '../DropDown'; -import IcDropDown from '../icons/IcDropDown'; -import { - StGenerationDropdown, - StMenu, - StNavWrapper, - StSoptLogo, - StSubMenu, -} from './style'; +import GenerationDropDown from './GenerationDropDown'; +import { StMenu, StNavWrapper, StSoptLogo, StSubMenu } from './style'; function Nav() { const router = useRouter(); - const [currentGeneration, setCurrentGeneration] = useRecoilGenerationSSR(); - const [isDropdownOn, setIsDropdownOn] = useState(false); + const { status } = useContext(adminStatusContext); - const dropdownRef = useRef(null); - - const handleSelectedGeneration = (selectedGeneration: string) => { - setCurrentGeneration(selectedGeneration); - setIsDropdownOn(false); - const pathSegments = router.asPath.split('/'); - if (pathSegments[pathSegments.length - 1].match(/^\d+$/)) { - router.push('/attendanceAdmin/session'); - } - }; + const filteredMenuList = + status === 'MAKERS' + ? MENU_LIST.filter((menu) => menu.title === '알림 관리') + : MENU_LIST; const handleSubMenuClick = (path: string) => { router.push(path); }; - const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { - setIsDropdownOn(false); - } - }; - - useEffect(() => { - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - return (
- SOPT - -
setIsDropdownOn(!isDropdownOn)}> - {currentGeneration}기 - -
- {isDropdownOn && ( - - )} -
+ SOPT ADMIN
- {MENU_LIST.map((menu) => ( - + + {filteredMenuList.map((menu) => ( + menu.path && handleSubMenuClick(menu.path[0])}>

- + {menu.title}

@@ -95,7 +51,7 @@ function Nav() { {subMenu} ))} -
+ ))}
); diff --git a/src/components/common/Nav/style.ts b/src/components/common/Nav/style.ts index 60a7cb9f..65d19b4c 100644 --- a/src/components/common/Nav/style.ts +++ b/src/components/common/Nav/style.ts @@ -1,46 +1,33 @@ import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; export const StNavWrapper = styled.nav` position: fixed; left: 0; top: 0; - width: 22rem; + width: 21.2rem; height: 100%; - background-color: ${({ theme }) => theme.color.grayscale.white100}; + background-color: ${colors.gray900}; & > header { display: flex; flex-direction: column; - gap: 4rem; + gap: 2.3rem; - padding: 4rem 4rem 3.6rem 4rem; + padding: 4.3rem 4rem 3.3rem 3rem; - color: ${({ theme }) => theme.color.grayscale.black40}; + color: ${colors.gray10}; } `; export const StSoptLogo = styled.h1` + font-size: 2.1rem; + font-style: normal; font-weight: 700; - font-size: 24px; - line-height: 100%; - letter-spacing: -0.02em; -`; - -export const StGenerationDropdown = styled.div` - cursor: pointer; - - & > div { - cursor: pointer; - & > span { - margin-right: 0.4rem; - - font-weight: 600; - font-size: 24px; - line-height: 140%; - letter-spacing: -0.02em; - } - } + line-height: 140%; + letter-spacing: -0.084rem; + color: ${colors.gray10}; `; export const StMenu = styled.div<{ currentPage: boolean | undefined }>` @@ -48,38 +35,41 @@ export const StMenu = styled.div<{ currentPage: boolean | undefined }>` flex-direction: column; gap: 1.6rem; - margin: 0 1.6rem 0.8rem 1.6rem; + margin: 0 1.8rem 0.6rem 1.8rem; + font-size: 1.6rem; + font-style: normal; font-weight: 600; - font-size: 16px; - line-height: 100%; - letter-spacing: -0.02em; + line-height: 150%; + letter-spacing: -0.024rem; + + color: ${colors.gray300}; & > p { display: flex; align-items: center; - gap: 2rem; + gap: 1.2rem; width: 100%; - padding: 1.6rem 2.4rem 1.6rem 2.4rem; + padding: 1.3rem 1.4rem 1.3rem 2.4rem; text-align: center; - color: ${({ theme, currentPage }) => - currentPage - ? theme.color.grayscale.white100 - : theme.color.grayscale.gray60}; - background: ${({ theme, currentPage }) => - currentPage - ? theme.color.grayscale.black40 - : theme.color.grayscale.gray10}; - border-radius: 8rem; + background: ${({ currentPage }) => + currentPage ? colors.gray800 : '#fffff'}; + border-radius: 1rem; cursor: pointer; &:hover { - color: ${({ theme }) => theme.color.grayscale.white100}; - background: ${({ theme }) => theme.color.grayscale.black40}; + background: ${colors.gray800}; + } + &:active { + color: ${colors.gray10}; + background-color: ${colors.gray700}; + & > svg > path { + fill: #fcfcfc; + } } } `; @@ -88,24 +78,29 @@ export const StSubMenu = styled.p<{ currentPage: boolean | undefined; isLast: boolean; }>` - padding: 1.6rem 6.9rem 1.6rem 6rem; - margin: 0 1.6rem; + padding: 1.3rem 6.7rem 1.3rem 2.4rem; + margin: 0.6rem 1.8rem 0.6rem 4.4rem; margin-bottom: ${({ isLast }) => (isLast ? '1.6rem' : '0')}; - font-weight: 400; - font-size: 16px; - line-height: 100%; - letter-spacing: -0.02em; - - color: ${({ theme, currentPage }) => - currentPage ? theme.color.grayscale.black40 : theme.color.grayscale.gray60}; + font-family: SUIT; + font-size: 1.6rem; + font-style: normal; + font-weight: 600; + line-height: 150%; + letter-spacing: -0.024rem; + color: ${({ currentPage }) => (currentPage ? colors.gray10 : colors.gray300)}; + background-color: ${({ currentPage }) => + currentPage ? colors.gray700 : 'fffff'}; + border-radius: 1rem; cursor: pointer; &:hover { - color: ${({ theme }) => theme.color.grayscale.black40}; + color: ${colors.gray300}; + background-color: ${colors.gray800}; } &:active { - color: ${({ theme }) => theme.color.grayscale.black40}; + color: ${colors.gray10}; + background-color: ${colors.gray700}; } `; diff --git a/src/components/common/OptionTemplate/index.tsx b/src/components/common/OptionTemplate/index.tsx new file mode 100644 index 00000000..0e69ba78 --- /dev/null +++ b/src/components/common/OptionTemplate/index.tsx @@ -0,0 +1,21 @@ +import { ReactNode } from 'react'; + +import { StTemplateWrapper } from './style'; + +interface Props { + title: string; + children: ReactNode; +} + +function OptionTemplate(props: Props) { + const { title, children } = props; + + return ( + +

{title}

+ {children} +
+ ); +} + +export default OptionTemplate; diff --git a/src/components/common/OptionTemplate/style.ts b/src/components/common/OptionTemplate/style.ts new file mode 100644 index 00000000..34f96e8b --- /dev/null +++ b/src/components/common/OptionTemplate/style.ts @@ -0,0 +1,18 @@ +import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; +import { fonts } from '@sopt-makers/fonts'; + +export const StTemplateWrapper = styled.div` + position: relative; + display: flex; + flex-direction: column; + gap: 0.6rem; + + & > p { + margin-top: 1.6rem; + + ${fonts.LABEL_14_SB} + + color: ${colors.gray300}; + } +`; diff --git a/src/components/common/PartFilter/index.tsx b/src/components/common/PartFilter/index.tsx index a0e54ac7..d31f1c68 100644 --- a/src/components/common/PartFilter/index.tsx +++ b/src/components/common/PartFilter/index.tsx @@ -1,20 +1,25 @@ import FilterButton from '@/components/common/FilterButton'; -import { partList, partTranslator } from '@/utils/translator'; +import { + allPartTranslator, + partList, + partTranslator, +} from '@/utils/translator'; interface Props { selected: PART; onChangePart: (part: PART) => void; + isAllPart?: boolean; } function PartFilter(props: Props) { - const { selected, onChangePart } = props; + const { selected, onChangePart, isAllPart = false } = props; return ( list={partList} selected={selected} onChange={onChangePart} - translator={partTranslator} + translator={isAllPart ? allPartTranslator : partTranslator} /> ); } diff --git a/src/components/common/Selector/index.tsx b/src/components/common/Selector/index.tsx new file mode 100644 index 00000000..3389039d --- /dev/null +++ b/src/components/common/Selector/index.tsx @@ -0,0 +1,26 @@ +import { IcNewDropdown } from '@/assets/icons'; + +import { StSelectorWrapper } from './style'; + +interface Props { + content: string | null; + onClick?: () => void; + isDisabledValue?: boolean; + readOnly?: boolean; +} + +function Selector(props: Props) { + const { content, onClick, isDisabledValue = false, readOnly = false } = props; + + return ( + + {content} + + + ); +} + +export default Selector; diff --git a/src/components/common/Selector/style.ts b/src/components/common/Selector/style.ts new file mode 100644 index 00000000..da28db47 --- /dev/null +++ b/src/components/common/Selector/style.ts @@ -0,0 +1,36 @@ +import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; + +export const StSelectorWrapper = styled.div<{ + isDisabledValue: boolean; + readOnly: boolean; +}>` + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.7rem; + + min-width: 8.6rem; + + padding: 1rem 1.4rem; + + font-size: 1.8rem; + font-style: normal; + font-weight: 500; + line-height: 100%; /* 1.8rem */ + letter-spacing: -0.018rem; + + color: ${({ isDisabledValue }) => + isDisabledValue ? colors.gray400 : colors.gray10}; + + background-color: ${colors.gray700}; + border-radius: 0.8rem; + + cursor: ${({ isDisabledValue, readOnly }) => + isDisabledValue || readOnly ? 'default' : 'pointer'}; + + & > svg > path { + fill: ${({ isDisabledValue }) => + isDisabledValue ? colors.gray400 : colors.gray10}; + } +`; diff --git a/src/components/common/modal/ModalFooter/index.tsx b/src/components/common/modal/ModalFooter/index.tsx new file mode 100644 index 00000000..361ef38d --- /dev/null +++ b/src/components/common/modal/ModalFooter/index.tsx @@ -0,0 +1,14 @@ +import { ReactNode } from 'react'; + +import { StModalFooterWrapper } from './style'; + +interface Props { + children: ReactNode; +} + +function ModalFooter(props: Props) { + const { children } = props; + return {children}; +} + +export default ModalFooter; diff --git a/src/components/common/modal/ModalFooter/style.ts b/src/components/common/modal/ModalFooter/style.ts new file mode 100644 index 00000000..cee5b9b5 --- /dev/null +++ b/src/components/common/modal/ModalFooter/style.ts @@ -0,0 +1,15 @@ +import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; + +export const StModalFooterWrapper = styled.footer` + display: flex; + align-items: center; + + height: 9.6rem; + + padding: 2.4rem 4rem; + + background-color: ${colors.gray700}; + + border-radius: 0 0 1.2rem 1.2rem; +`; diff --git a/src/components/common/modal/ModalHeader/index.tsx b/src/components/common/modal/ModalHeader/index.tsx new file mode 100644 index 00000000..befc91a7 --- /dev/null +++ b/src/components/common/modal/ModalHeader/index.tsx @@ -0,0 +1,25 @@ +import { IcModalClose } from '@/assets/icons'; + +import { StModalHeader } from './style'; + +interface Props { + title: string; + desc: string; + onClose: () => void; +} + +function ModalHeader(props: Props) { + const { title, desc, onClose } = props; + + return ( + +
+

{title}

+

{desc}

+
+ +
+ ); +} + +export default ModalHeader; diff --git a/src/components/common/modal/ModalHeader/style.ts b/src/components/common/modal/ModalHeader/style.ts new file mode 100644 index 00000000..84612822 --- /dev/null +++ b/src/components/common/modal/ModalHeader/style.ts @@ -0,0 +1,47 @@ +import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; + +export const StModalHeader = styled.header` + display: flex; + justify-content: space-between; + align-items: center; + + padding: 2.2rem 3.2rem 0.8rem 3.2rem; + + & > div.title { + display: flex; + justify-content: center; + align-items: center; + gap: 1.6rem; + + & > h1 { + font-size: 2.8rem; + font-style: normal; + font-weight: 700; + line-height: 150%; + letter-spacing: -0.056rem; + + color: ${colors.gray10}; + } + + & > h2 { + font-size: 1.4rem; + font-style: normal; + font-weight: 400; + line-height: 160%; + letter-spacing: -0.021rem; + + color: ${colors.gray300}; + } + } + & > svg { + cursor: pointer; + + &:hover { + fill: ${colors.gray700}; + } + &:active { + fill: ${colors.gray600}; + } + } +`; diff --git a/src/components/common/modal/style.ts b/src/components/common/modal/style.ts index 62b7260e..bc92d01c 100644 --- a/src/components/common/modal/style.ts +++ b/src/components/common/modal/style.ts @@ -1,4 +1,5 @@ import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; import zIndex from '@/utils/zIndex'; @@ -8,7 +9,7 @@ export const StModalBackground = styled.div` left: 0; width: 100%; height: 100%; - background-color: rgba(0, 0, 0, 0.4); + background-color: rgba(15, 15, 18, 0.8); z-index: ${zIndex.dim}; display: flex; justify-content: center; @@ -16,10 +17,10 @@ export const StModalBackground = styled.div` `; export const StModalWrapper = styled.div` - width: 90rem; + width: auto; height: auto; - background-color: ${({ theme }) => theme.color.grayscale.white100}; + background-color: ${colors.gray800}; box-shadow: 0px 0px 40px rgba(16, 24, 40, 0.06); border-radius: 1.2rem; diff --git a/src/components/devTools/AdminContextProvider/index.tsx b/src/components/devTools/AdminContextProvider/index.tsx new file mode 100644 index 00000000..a75d7217 --- /dev/null +++ b/src/components/devTools/AdminContextProvider/index.tsx @@ -0,0 +1,44 @@ +import { + createContext, + Dispatch, + ReactNode, + SetStateAction, + useEffect, + useState, +} from 'react'; + +interface Props { + children: ReactNode; +} + +interface AdminStatusContextType { + status: string | null; + setStatus: Dispatch>; +} + +const defaultContextValue: AdminStatusContextType = { + status: 'DEVELOPER', + setStatus: () => {}, +}; + +export const adminStatusContext = + createContext(defaultContextValue); + +export const AdminStatusProvider = (props: Props) => { + const { children } = props; + const [status, setStatus] = useState('NOT_CERTIFIED'); + + useEffect(() => { + if (typeof window !== 'undefined') { + const savedStatus = + sessionStorage.getItem('adminStatus') ?? 'NOT_CERTIFIED'; + setStatus(savedStatus); + } + }, []); + + return ( + + {children} + + ); +}; diff --git a/src/components/devTools/AdminStatus/index.tsx b/src/components/devTools/AdminStatus/index.tsx new file mode 100644 index 00000000..8c3f3dc7 --- /dev/null +++ b/src/components/devTools/AdminStatus/index.tsx @@ -0,0 +1,59 @@ +import { useRouter } from 'next/router'; +import { useContext, useEffect, useState } from 'react'; + +import DropDown from '@/components/common/DropDown'; +import OptionTemplate from '@/components/common/OptionTemplate'; +import Selector from '@/components/common/Selector'; + +import { adminStatusContext } from '../AdminContextProvider'; +import { StAdminStatusContainer } from './style'; + +function AdminStatusDevtools() { + const router = useRouter(); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const { status, setStatus } = useContext(adminStatusContext); + + const STATUS_SELECTION: ADMIN_STATUS[] = ['SOPT', 'MAKERS']; + + const isAdminStatus = (value: string): value is ADMIN_STATUS => { + return [ + 'SUPER_USER', + 'SOPT', + 'MAKERS', + 'NOT_CERTIFIED', + 'DEVELOPER', + ].includes(value); + }; + + const handleStatusChange = (newStatus: string) => { + if (isAdminStatus(newStatus)) { + setStatus(newStatus); + sessionStorage.setItem('adminStatus', newStatus); + if (newStatus === 'MAKERS') { + router.push('/alarmAdmin'); + } + } else { + console.error('유효하지 않은 권한입니다.'); + } + }; + + return ( + + + setIsDropdownOpen(!isDropdownOpen)} + /> + {isDropdownOpen && ( + + )} + + + ); +} + +export default AdminStatusDevtools; diff --git a/src/components/devTools/AdminStatus/style.ts b/src/components/devTools/AdminStatus/style.ts new file mode 100644 index 00000000..54aecb96 --- /dev/null +++ b/src/components/devTools/AdminStatus/style.ts @@ -0,0 +1,5 @@ +import styled from '@emotion/styled'; + +export const StAdminStatusContainer = styled.div` + width: 18.5rem; +`; diff --git a/src/components/session/Select/index.tsx b/src/components/session/Select/index.tsx index 3fa2a1ad..18e12e80 100644 --- a/src/components/session/Select/index.tsx +++ b/src/components/session/Select/index.tsx @@ -1,9 +1,8 @@ -import { useTheme } from '@emotion/react'; import { useCallback, useEffect, useRef, useState } from 'react'; -import IcDropdown from '@/components/icons/IcDropdown'; -import { ACTIVITY_GENRATION } from '@/utils/generation'; -import { attendanceTranslator, getAttendanceColor } from '@/utils/translator'; +import IcDropdownCheck from '@/assets/icons/IcDropdownCheck.svg'; +import { ACTIVITY_GENERATION } from '@/utils/generation'; +import { attendanceTranslator } from '@/utils/translator'; import { StOptions, StSelect, StSelectWrap } from './style'; @@ -11,11 +10,12 @@ interface Props { options: Array<{ label: string; value: ATTEND_STATUS }>; selected: ATTEND_STATUS; generation: string; + round: '1차' | '2차'; onChange: (value: ATTEND_STATUS) => void; } function Select(props: Props) { - const { options, selected, generation, onChange } = props; + const { options, selected, generation, round, onChange } = props; const optionsRef = useRef(null); @@ -23,8 +23,8 @@ function Select(props: Props) { const [showOptions, setShowOptions] = useState(false); const toggleOptions = useCallback(() => { - setShowOptions(!showOptions); - }, [showOptions]); + setShowOptions((prevShowOptions) => !prevShowOptions); + }, []); useEffect(() => { const handleClickOutside = (event: any) => { @@ -47,13 +47,13 @@ function Select(props: Props) { return ( - -

- {attendanceTranslator[currentValue]} + +

+ {round} {attendanceTranslator[currentValue]} +

- {generation === ACTIVITY_GENRATION && }
- {showOptions && generation === ACTIVITY_GENRATION && ( + {showOptions && generation === ACTIVITY_GENERATION && ( {options.map((option) => (
  • onClickOption(option.value)}> diff --git a/src/components/session/Select/style.ts b/src/components/session/Select/style.ts index e6e3740f..a75e9d1f 100644 --- a/src/components/session/Select/style.ts +++ b/src/components/session/Select/style.ts @@ -1,4 +1,6 @@ import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; +import { fonts } from '@sopt-makers/fonts'; import zIndex from '@/utils/zIndex'; @@ -7,31 +9,50 @@ export const StSelectWrap = styled.div` justify-content: center; align-items: center; `; -export const StSelect = styled.button` - background: none; - font-size: 1.4rem; - font-weight: 500; - line-height: 2rem; - color: ${({ theme }) => theme.color.grayscale.black40}; +export const StSelect = styled.button<{ + value: ATTEND_STATUS | ATTEND_STATUS_KR; +}>` + ${fonts.BODY_14_M} display: flex; align-items: center; - gap: 1.2rem; - transform: translateX(0.6rem); + padding: 3.5px 8px 3.5px 12px; + gap: 12px; + border-radius: 8px; + background-color: ${({ value }) => getAttendanceColor(value).background}; + margin-right: 20px; + + p { + color: ${({ value }) => getAttendanceColor(value).font}; + } + svg { + margin-left: 12px; + } + path { + fill: ${({ value }) => getAttendanceColor(value).font}; + } `; export const StOptions = styled.ul` + ${fonts.BODY_14_M} + z-index: ${zIndex.select}; position: absolute; - transform: translateY(6.5rem); - background-color: ${({ theme }) => theme.color.grayscale.realwhite}; - border-radius: 1rem; - padding: 0.7rem; - box-shadow: 0 10px 20px 0 rgba(0, 0, 0, 0.1); + transform: translate(-1rem, 6.2rem); + + background-color: ${colors.gray600}; + box-shadow: 0px 5px 20px 0px rgba(0, 0, 0, 0.1); + border-radius: 13px; + padding: 8px 6px; animation: appear 0.6s; + li { + padding: 5px 21px 5px 9px; + border: none; border-radius: 0.6rem; - padding: 0.9rem 1.7rem; + color: ${colors.gray10}; + background-color: ${colors.gray600}; + &:hover { - background-color: ${({ theme }) => theme.color.grayscale.gray20}; + background-color: ${colors.gray500}; cursor: pointer; } } @@ -39,11 +60,27 @@ export const StOptions = styled.ul` @keyframes appear { from { opacity: 0; - transform: translateY(5.5rem); + transform: translate(-1rem, 5.2rem); } to { opacity: 1; - transform: translateY(6.5rem); + transform: translate(-1rem, 6.2rem); } } `; + +const getAttendanceColor = (value: ATTEND_STATUS | ATTEND_STATUS_KR) => { + switch (value) { + case 'ABSENT': + case '결석': + return { font: colors.red300, background: colors.red800 }; + case 'TARDY': + case '지각': + return { font: colors.attention, background: colors.yellow900 }; + case 'ATTENDANCE': + case '출석': + return { font: colors.information, background: colors.green900 }; + default: + return { font: colors.gray10, background: colors.gray600 }; + } +}; diff --git a/src/data/sessionData.ts b/src/data/sessionData.ts index 9fb30ce9..b223e694 100644 --- a/src/data/sessionData.ts +++ b/src/data/sessionData.ts @@ -18,10 +18,19 @@ export const subLectureInit: SubLecture = { code: null, }; -export const attendanceOptions: Array<{ - label: string; - value: ATTEND_STATUS; -}> = [ - { label: '출석', value: 'ATTENDANCE' }, - { label: '결석', value: 'ABSENT' }, -]; +export const attendanceOptions: Record< + string, + Array<{ + label: string; + value: ATTEND_STATUS; + }> +> = { + first: [ + { label: '1차 출석', value: 'ATTENDANCE' }, + { label: '1차 결석', value: 'ABSENT' }, + ], + second: [ + { label: '2차 출석', value: 'ATTENDANCE' }, + { label: '2차 결석', value: 'ABSENT' }, + ], +}; diff --git a/src/hooks/useRecoilGenerationSSR.ts b/src/hooks/useRecoilGenerationSSR.ts index 279f9b7e..695b4e2c 100644 --- a/src/hooks/useRecoilGenerationSSR.ts +++ b/src/hooks/useRecoilGenerationSSR.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useRecoilState } from 'recoil'; import { currentGenerationState } from '@/recoil/atom'; -import { ACTIVITY_GENRATION } from '@/utils/generation'; +import { ACTIVITY_GENERATION } from '@/utils/generation'; export const useRecoilGenerationSSR = () => { const [isInitial, setIsInitial] = useState(true); @@ -12,5 +12,5 @@ export const useRecoilGenerationSSR = () => { setIsInitial(false); }, []); - return [isInitial ? ACTIVITY_GENRATION : value, setValue] as const; + return [isInitial ? ACTIVITY_GENERATION : value, setValue] as const; }; diff --git a/src/hooks/useUnauthorizedStatus.ts b/src/hooks/useUnauthorizedStatus.ts new file mode 100644 index 00000000..30b63c53 --- /dev/null +++ b/src/hooks/useUnauthorizedStatus.ts @@ -0,0 +1,16 @@ +import { useRouter } from 'next/router'; +import { useEffect } from 'react'; + +export const useUnauthorizedStatus = (status: ADMIN_STATUS) => { + const router = useRouter(); + + useEffect(() => { + if ( + typeof window !== 'undefined' && + sessionStorage.getItem('adminStatus') === status + ) { + alert('접근 권한이 없는 계정입니다.'); + router.back(); + } + }, [router, status]); +}; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 35d14e68..e765af8a 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,5 +1,6 @@ import { Global, ThemeProvider } from '@emotion/react'; import type { AppProps } from 'next/app'; +import Head from 'next/head'; import { useRouter } from 'next/router'; import { useEffect } from 'react'; import { Hydrate, QueryClient, QueryClientProvider } from 'react-query'; @@ -7,6 +8,7 @@ import { ReactQueryDevtools } from 'react-query/devtools'; import { RecoilRoot } from 'recoil'; import Layout from '@/components/common/Layout'; +import { AdminStatusProvider } from '@/components/devTools/AdminContextProvider'; import global from '@/styles/global'; import theme from '@/styles/theme'; import { getToken } from '@/utils/auth'; @@ -29,18 +31,25 @@ export default function App({ Component, pageProps }: AppProps) { }, []); return ( - - - - - - - - - - - - - + <> + + SOPT Admin + + + + + + + + + + + + + + + + + ); } diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx index af2f1aba..a53754b4 100644 --- a/src/pages/_document.tsx +++ b/src/pages/_document.tsx @@ -3,7 +3,11 @@ import { Head, Html, Main, NextScript } from 'next/document'; export default function Document() { return ( - + + + + +
    diff --git a/src/pages/alarmAdmin/index.tsx b/src/pages/alarmAdmin/index.tsx new file mode 100644 index 00000000..dfe9ddf8 --- /dev/null +++ b/src/pages/alarmAdmin/index.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react'; +import { useRecoilValue } from 'recoil'; + +import AlarmList from '@/components/alarmAdmin/AlarmList'; +import CreateAlarmModal from '@/components/alarmAdmin/CreateAlarmModal'; +import FloatingButton from '@/components/common/FloatingButton'; +import Loading from '@/components/common/Loading'; +import Modal from '@/components/common/modal'; +import { currentGenerationState } from '@/recoil/atom'; +import { useGetAlarmList } from '@/services/api/alarm/query'; + +function AlarmAdminPage() { + const [isModalOpen, setIsModalOpen] = useState(false); + + const currentGeneration = useRecoilValue(currentGenerationState); + + const { data, isLoading, refetch } = useGetAlarmList( + parseInt(currentGeneration), + ); + + const handleModalClose = async () => { + setIsModalOpen(!isModalOpen); + refetch(); + }; + + const refetchAlarmList = () => { + refetch(); + }; + + if (isLoading || !data) return ; + return ( + <> + + 알림 생성하기} + onClick={() => setIsModalOpen(!isModalOpen)} + /> + {isModalOpen && ( + + + + )} + + ); +} + +export default AlarmAdminPage; diff --git a/src/pages/attendanceAdmin/session/[id].tsx b/src/pages/attendanceAdmin/session/[id].tsx index 91bf48b7..698b56d5 100644 --- a/src/pages/attendanceAdmin/session/[id].tsx +++ b/src/pages/attendanceAdmin/session/[id].tsx @@ -1,11 +1,13 @@ import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; +import { fonts } from '@sopt-makers/fonts'; import dayjs from 'dayjs'; import { useRouter } from 'next/router'; -import { RefObject, useRef, useState } from 'react'; +import { ReactNode, RefObject, useRef, useState } from 'react'; import AttendanceModal from '@/components/attendanceAdmin/session/AttendanceModal'; -import Button from '@/components/common/Button'; -import Footer from '@/components/common/Footer'; +import Chip from '@/components/common/Chip'; +import FloatingButton from '@/components/common/FloatingButton'; import HelperText from '@/components/common/HelperText'; import ListActionButton from '@/components/common/ListActionButton'; import ListWrapper from '@/components/common/ListWrapper'; @@ -16,6 +18,7 @@ import Select from '@/components/session/Select'; import { PAGE_SIZE } from '@/data/queryData'; import { attendanceInit, attendanceOptions } from '@/data/sessionData'; import useObserver from '@/hooks/useObserver'; +import { useUnauthorizedStatus } from '@/hooks/useUnauthorizedStatus'; import { updateMemberAttendStatus, updateMemberScore, @@ -26,20 +29,15 @@ import { useGetSessionDetail, } from '@/services/api/lecture/query'; import { addPlus, precision } from '@/utils'; -import { ACTIVITY_GENRATION } from '@/utils/generation'; - -const HEADER_LABELS = [ - '순번', - '회원명', - '학교명', - '1차 출석 상태', - '1차 출석 일시', - '2차 출석 상태', - '2차 출석 일시', - '변동점수', - 'ㅤ', -]; -const TABLE_WIDTH = ['9%', '9%', '12%', '10%', '16%', '10%', '16%', '9%', '9%']; +import { ACTIVITY_GENERATION } from '@/utils/generation'; +import { allPartTranslator, attributeTranslator } from '@/utils/translator'; + +interface ChangedUpdatedStatus { + memberId: number; + firstRoundStatus: ATTEND_STATUS; + secondRoundStatus: ATTEND_STATUS; + updatedScore: number; +} function SessionDetailPage() { const router = useRouter(); @@ -48,9 +46,14 @@ function SessionDetailPage() { const [selectedPart, setSelectedPart] = useState('ALL'); const [changedMembers, setChangedMembers] = useState([]); + const [changedUpdatedStatusList, setChangedUpdatedStatusList] = useState< + ChangedUpdatedStatus[] + >([]); const [modal, setModal] = useState(null); const bottomRef: RefObject = useRef(null); + useUnauthorizedStatus('MAKERS'); + const { data: session, isLoading: isLoadingSession, @@ -75,38 +78,115 @@ function SessionDetailPage() { setSelectedPart(part); }; + const calcUpdatedScore = ( + memberId: number, + attendances: Attendance[], + round: number, + status: ATTEND_STATUS, + ) => { + const attribute = session?.attribute; + + const prevStatus = changedUpdatedStatusList.find( + (item) => item.memberId === memberId, + ); + const anotherRound = round === 1 ? 2 : 1; + const anotherRoundStatus = prevStatus + ? round === 1 + ? prevStatus.secondRoundStatus + : prevStatus.firstRoundStatus + : (attendances.find((attendance) => attendance.round === anotherRound) + ?.status as ATTEND_STATUS); + + const firstRoundStatus = round === 1 ? status : anotherRoundStatus; + const secondRoundStatus = round === 2 ? status : anotherRoundStatus; + let updatedScore = 0; + + switch (attribute) { + case 'SEMINAR': + if ( + firstRoundStatus === 'ATTENDANCE' && + secondRoundStatus === 'ATTENDANCE' + ) { + updatedScore = 0; + } else if ( + firstRoundStatus === 'ABSENT' && + secondRoundStatus === 'ATTENDANCE' + ) { + updatedScore = -0.5; + } else { + updatedScore = -1; + } + break; + case 'EVENT': + if ( + (firstRoundStatus === 'ATTENDANCE' || + firstRoundStatus === 'ABSENT') && + secondRoundStatus === 'ATTENDANCE' + ) { + updatedScore = 0.5; + } else { + updatedScore = 0; + } + break; + case 'ETC': + updatedScore = 0; + break; + } + + const newList = changedUpdatedStatusList.filter( + (item) => item.memberId !== memberId, + ); + setChangedUpdatedStatusList([ + ...newList, + { memberId, firstRoundStatus, secondRoundStatus, updatedScore }, + ]); + }; + const onChangeStatus = async ( status: ATTEND_STATUS, member: SessionMember, - subAttendanceId: number, + round: number, + ) => { + setChangedMembers([...changedMembers, member]); + calcUpdatedScore(member.member.memberId, member.attendances, round, status); + }; + + const onUpdateScore = async ( + memberId: number, + firstSubAttendanceId: number, + secondSubAttendanceId: number, ) => { if (session) { - const result = await updateMemberAttendStatus( - subAttendanceId, - status, + const { firstRoundStatus, secondRoundStatus, updatedScore } = + changedUpdatedStatusList.find( + (item) => item.memberId === memberId, + ) as ChangedUpdatedStatus; + + const firstRoundError = await updateMemberAttendStatus( + firstSubAttendanceId, + firstRoundStatus, session.attribute, ); - if (result) { - alert(result.error); + const secondRoundError = await updateMemberAttendStatus( + secondSubAttendanceId, + secondRoundStatus, + session.attribute, + ); + const updateScoreError = await updateMemberScore(memberId); + + if (firstRoundError || secondRoundError || updateScoreError) { + alert('출석 점수를 갱신하는데 실패했어요'); } else { - setChangedMembers([...changedMembers, member]); - // refetchMembers(); + setChangedMembers( + changedMembers.filter( + (member) => member.member.memberId !== memberId, + ), + ); + refetchSession(); } } }; - const onUpdateScore = async (memberId: number) => { - const result = await updateMemberScore(memberId); - if (result) { - alert(result.error); - } else { - setChangedMembers( - changedMembers.filter((member) => member.member.memberId !== memberId), - ); - refetchSession(); - } - }; - const isChangedMember = (member: SessionMember) => { return changedMembers.find( (item) => item.member.memberId === member.member.memberId, @@ -140,111 +220,151 @@ function SessionDetailPage() { } }; + const getMemberCount = (attendances: Record) => { + let sum = 0; + Object.keys(attendances).map((key) => (sum += attendances[key])); + return sum; + }; + + const getButtonContent = (status: SESSION_STATUS): ReactNode => { + switch (status) { + case 'BEFORE': + return <>1차 출석 시작하기; + case 'FIRST': + return <>2차 출석 시작하기; + case 'SECOND': + return <>출석 완료하기; + default: + return <>; + } + }; + + const getButtonClickHandler = (status: SESSION_STATUS) => { + switch (status) { + case 'BEFORE': + return () => startAttendance(1); + case 'FIRST': + return () => startAttendance(2); + case 'SECOND': + return () => closeAttendance(); + default: + // eslint-disable-next-line prettier/prettier + return () => {}; + } + }; + return ( {session && ( -
    -

    - {session.name} 출석 관리 -

    -
    +
    +

    {session.name} 출석 관리

    + + +
    + {session.part === 'ALL' && ( + + )} +
    +

    총 {getMemberCount(session.attendances)}명

    +

    출석 {session.attendances.attendance}

    지각 {session.attendances.tardy}

    결석 {session.attendances.absent}

    미정 {session.attendances.unknown}

    - {session.part === 'ALL' && ( - - )} )} {session && members ? ( - - - - {HEADER_LABELS.map((label) => ( - {label} - ))} - - - - {members?.pages.map( - (pageMembers, pageIndex) => - pageMembers && - pageMembers.map((member, index) => { - const firstRound = - member.attendances.find((item) => item.round === 1) ?? - attendanceInit; - const secondRound = - member.attendances.find((item) => item.round === 2) ?? - attendanceInit; - const firstRoundTime = dayjs(firstRound.updateAt).format( - 'YYYY/MM/DD HH:mm', - ); - const secondRoundTime = dayjs(secondRound.updateAt).format( - 'YYYY/MM/DD HH:mm', - ); - return ( - - {precision(pageIndex * PAGE_SIZE + index + 1, 2)} - {member.member.name} - + + {members?.pages.map( + (pageMembers, pageIndex) => + pageMembers && + pageMembers.map((member, index) => { + const firstRound = + member.attendances.find((item) => item.round === 1) ?? + attendanceInit; + const secondRound = + member.attendances.find((item) => item.round === 2) ?? + attendanceInit; + const firstRoundTime = dayjs(firstRound.updateAt).format( + 'YYYY/MM/DD HH:mm', + ); + const secondRoundTime = dayjs(secondRound.updateAt).format( + 'YYYY/MM/DD HH:mm', + ); + const updatedScore = + changedUpdatedStatusList.find( + (item) => item.memberId === member.member.memberId, + )?.updatedScore ?? member.updatedScore; + + return ( + +

    + {precision(pageIndex * PAGE_SIZE + index + 1, 2)} +

    +
    +
    +

    {member.member.name}

    + +
    +

    {member.member.university} - - - - onChangeStatus( - value, - member, - secondRound.subAttendanceId, - ) - } - generation={String(session.generation)} - /> - - {secondRoundTime} - {addPlus(member.updatedScore)}점 - - onUpdateScore(member.member.memberId)} - text="갱신" - disabled={ - !( - session.status === 'END' && - isChangedMember(member) && - String(session.generation) === ACTIVITY_GENRATION - ) - } - /> - - - ); - }), - )} - +

    +
    + onChangeStatus(value, member, 2)} + generation={String(session.generation)} + /> +

    {secondRoundTime}

    +
    +

    + {addPlus(updatedScore)}점 +

    +
    + + onUpdateScore( + member.member.memberId, + firstRound.subAttendanceId, + secondRound.subAttendanceId, + ) + } + text="갱신" + disabled={ + !( + session.status === 'END' && + isChangedMember(member) && + String(session.generation) === ACTIVITY_GENERATION + ) + } + /> +
    + ); + }), + )}
    ) : (

    데이터가 없어요

    @@ -253,41 +373,21 @@ function SessionDetailPage() {
    {isFetchingNextPage && } - {session && ( -
    - -
    -
    -
    - {session.status == 'SECOND' && ( - - )} -
    -
    -
    + {session && session.status !== 'END' && ( + <> + {session.status == 'SECOND' && ( + + )} + + )} {session && modal && ( @@ -306,23 +406,6 @@ function SessionDetailPage() { } const StPageWrapper = styled.div` - .member { - &-name, - &-university { - max-width: 7.3rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin: 0 auto; - padding: 0 2.2rem; - } - &-university { - max-width: 13.5rem; - } - &-date { - color: ${({ theme }) => theme.color.grayscale.gray80}; - } - } .empty { text-align: center; font-size: 1.4rem; @@ -331,49 +414,99 @@ const StPageWrapper = styled.div` } `; const StPageHeader = styled.div` - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 6rem; - h2 { - font-size: 2rem; - font-weight: 600; - line-height: 2.5rem; - height: 3rem; - margin-bottom: 1.2rem; - color: ${({ theme }) => theme.color.grayscale.black60}; + h1 { + ${fonts.TITLE_32_SB} + color: ${colors.gray10}; + margin-right: 20px; + } + .title { display: flex; - strong { - margin-right: 0.8rem; - - font-weight: 700; - max-width: 18rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - display: inline-block; - } + align-items: center; + margin-bottom: 41px; } - .attendance-info { + .attendances { display: flex; - gap: 1rem; - font-size: 1.4rem; - line-height: 1.7rem; - color: ${({ theme }) => theme.color.grayscale.gray100}; + align-items: center; + gap: 20px; + margin-top: 56px; + margin-bottom: 18px; + + & > p { + ${fonts.TITLE_16_SB} + color: ${colors.gray200}; + } + & > div { + ${fonts.TITLE_14_SB} + color: ${colors.gray400}; + display: flex; + gap: 11px; + } } `; -const StFooterContents = styled.div` +const StListItem = styled.li` + padding: 18px 34px; display: flex; align-items: center; - justify-content: space-between; - .button-wrap { + color: ${colors.gray100}; + + .member-index { + ${fonts.BODY_14_M} + width: 26px; + margin-right: 33px; + } + .member-info { + margin-right: 26px; + } + .member-info > div:first-of-type { display: flex; - gap: 2.4rem; + align-items: center; + margin-bottom: 4px; + width: 144px; + } + .member-name { + ${fonts.TITLE_18_SB} + color: ${colors.gray30}; + margin-right: 15px; + max-width: 80px; + } + .member-university { + ${fonts.BODY_14_M} + color: ${colors.gray400}; + max-width: 140px; + } + .member-name, + .member-university { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .member-date { + ${fonts.BODY_14_M} + color: ${colors.gray200}; + width: 122px; + margin-right: 36px; + } + .member-score-wrap { + width: 80px; + margin-right: 22px; + } + .member-score { + ${fonts.BODY_16_M} + color: ${colors.gray50}; + background-color: ${colors.gray700}; + padding: 5px 13px; + border-radius: 30px; + width: fit-content; + margin: 0 auto; + } + .minus-score { + color: ${colors.error}; } `; const StHelperTextWrapper = styled.div` - position: absolute; - transform: translate(-122px, -86px); + position: fixed; + bottom: 126px; + right: 60px; `; export default SessionDetailPage; diff --git a/src/pages/attendanceAdmin/session/index.tsx b/src/pages/attendanceAdmin/session/index.tsx index f417d7c9..f0b4ade0 100644 --- a/src/pages/attendanceAdmin/session/index.tsx +++ b/src/pages/attendanceAdmin/session/index.tsx @@ -2,12 +2,15 @@ import { useEffect, useState } from 'react'; import CreateSessionModal from '@/components/attendanceAdmin/session/CreateSessionModal'; import SessionList from '@/components/attendanceAdmin/session/SessionList'; -import SessionListFooter from '@/components/attendanceAdmin/session/SessionListFooter'; -import Footer from '@/components/common/Footer'; +import FloatingButton from '@/components/common/FloatingButton'; import Modal from '@/components/common/modal'; +import { useUnauthorizedStatus } from '@/hooks/useUnauthorizedStatus'; + function SessionPage() { const [isModalOpen, setIsModalOpen] = useState(false); + useUnauthorizedStatus('MAKERS'); + useEffect(() => { if (isModalOpen) { document.body.style.overflow = 'hidden'; @@ -27,9 +30,10 @@ function SessionPage() { )} -
    - setIsModalOpen(!isModalOpen)} /> -
    + 세션 생성하기} + onClick={() => setIsModalOpen(!isModalOpen)} + /> ); } diff --git a/src/pages/attendanceAdmin/totalScore/index.tsx b/src/pages/attendanceAdmin/totalScore/index.tsx index b2de9d10..4055532e 100644 --- a/src/pages/attendanceAdmin/totalScore/index.tsx +++ b/src/pages/attendanceAdmin/totalScore/index.tsx @@ -1,6 +1,9 @@ import MemberList from '@/components/attendanceAdmin/totalScore/MemberList'; +import { useUnauthorizedStatus } from '@/hooks/useUnauthorizedStatus'; function TotalScorePage() { + useUnauthorizedStatus('MAKERS'); + return ; } diff --git a/src/pages/index.tsx b/src/pages/index.tsx index d53f5cad..8e5ec5e2 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,11 +1,15 @@ import styled from '@emotion/styled'; +import { colors } from '@sopt-makers/colors'; +import { fonts } from '@sopt-makers/fonts'; import Head from 'next/head'; import { useRouter } from 'next/router'; -import { useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { useSetRecoilState } from 'recoil'; +import { SoptMainLogo } from '@/assets/icons/SoptLogos'; import Button from '@/components/common/Button'; import Loading from '@/components/common/Loading'; +import { adminStatusContext } from '@/components/devTools/AdminContextProvider'; import useInput from '@/hooks/useInput'; import { userLogin } from '@/services/api/auth'; import { user as userState } from '@/store/globalStore'; @@ -15,6 +19,7 @@ function LoginPage() { const router = useRouter(); const setUser = useSetRecoilState(userState); + const { status, setStatus } = useContext(adminStatusContext); const { state, onChange } = useInput({ email: '', @@ -39,7 +44,12 @@ function LoginPage() { setError({ status: true, message: result.message }); } else { setUser(result); - router.replace('/attendanceAdmin/session'); + setStatus(result.adminStatus); + router.replace( + result.adminStatus !== 'MAKERS' + ? '/attendanceAdmin/session' + : '/alarmAdmin', + ); } } }; @@ -48,13 +58,10 @@ function LoginPage() { <> SOPT Admin :: 로그인 - - -

    - SOPT web admin + web admin

    @@ -93,11 +100,16 @@ const StyledLogin = styled.div` align-items: center; height: 90vh; h1 { - font-size: 3.6rem; - line-height: 3.6rem; - letter-spacing: -0.02em; - color: ${({ theme }) => theme.color.grayscale.black60}; - margin-bottom: 6rem; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 1.7rem; + + ${fonts.TITLE_28_SB} + color: ${colors.white}; + + margin-bottom: 3.1rem; } strong { font-weight: 700; @@ -107,33 +119,42 @@ const StyledLogin = styled.div` flex-direction: column; } label { - font-size: 1.4rem; - font-weight: 500; - line-height: 2rem; - color: ${({ theme }) => theme.color.grayscale.gray100}; + margin-top: 1.6rem; margin-bottom: 0.6rem; + + ${fonts.LABEL_14_SB} + + color: ${colors.gray300}; } input { - width: 40rem; + width: 40.2rem; height: 4.4rem; - border-radius: 0.8rem; - border: 1px solid ${({ theme }) => theme.color.grayscale.gray30}; - margin-bottom: 4rem; + padding: 1rem 1.4rem; - font-size: 1.6rem; - font-weight: 500; - line-height: 2.4rem; - color: ${({ theme }) => theme.color.grayscale.black40}; + + ${fonts.LABEL_18_SB} + + color: ${colors.gray10}; + background-color: ${colors.gray700}; + border: none; + outline: none; + + border-radius: 0.8rem; + &::placeholder { - color: ${({ theme }) => theme.color.grayscale.gray40}; + color: ${colors.gray400}; } + &:focus { - outline: 1px solid ${({ theme }) => theme.color.grayscale.black40}; + background-color: ${colors.gray600}; + outline: 0.1rem solid ${colors.gray300}; } } button { width: 40rem; height: 5.6rem; + + margin-top: 4rem; border-radius: 1rem; font-size: 1.8rem; font-weight: 700; diff --git a/src/services/api/alarm/index.ts b/src/services/api/alarm/index.ts new file mode 100644 index 00000000..12d35ac9 --- /dev/null +++ b/src/services/api/alarm/index.ts @@ -0,0 +1,38 @@ +import { AxiosResponse } from 'axios'; + +import { client } from '@/services/api/client'; + +export const postNewAlarm = async (alarmData: PostAlarmData): Promise => { + await client.post('/alarms', alarmData); +}; + +export const getAlarmList = async (generation: number): Promise => { + const { data }: AxiosResponse<{ data: { alarms: Alarm[] } }> = + await client.get( + `/alarms?generation=${generation}&size=${100}`, // TODO:: 페이지네이션 적용 + ); + + return data.data.alarms; +}; + +export const sendAlarm = async (alarmId: number): Promise => { + const { data }: AxiosResponse<{ success: boolean }> = await client.post( + '/alarms/send', + { alarmId }, + ); + return data.success; +}; + +export const getAlarm = async (alarmId: number): Promise => { + const { data }: AxiosResponse<{ data: AlarmDetail }> = await client.get( + `/alarms/${alarmId}`, + ); + return data.data; +}; + +export const deleteAlarm = async (alarmId: number): Promise => { + const { data }: AxiosResponse<{ success: boolean }> = await client.delete( + `/alarms/${alarmId}`, + ); + return data.success; +}; diff --git a/src/services/api/alarm/query.ts b/src/services/api/alarm/query.ts new file mode 100644 index 00000000..323f9e67 --- /dev/null +++ b/src/services/api/alarm/query.ts @@ -0,0 +1,19 @@ +import { useQuery } from 'react-query'; + +import { getAlarm, getAlarmList } from './index'; + +export const useGetAlarmList = (generation: number) => { + return useQuery( + ['alarmList', generation], + () => getAlarmList(generation), + { staleTime: 10 * 60 * 1000 }, + ); +}; + +export const useGetAlarm = (alarmId: number) => { + return useQuery( + ['alarm', alarmId], + () => getAlarm(alarmId), + { staleTime: 10 * 60 * 1000 }, + ); +}; diff --git a/src/services/api/lecture/index.ts b/src/services/api/lecture/index.ts index 0edb82ce..8e3edf53 100644 --- a/src/services/api/lecture/index.ts +++ b/src/services/api/lecture/index.ts @@ -19,8 +19,11 @@ export const startAttendance = async ( lectureId: number, round: number, ): Promise => { - await client.patch('/lectures/attendance', { code, lectureId, round }); - return true; + const { data }: AxiosResponse<{ success: boolean }> = await client.patch( + '/lectures/attendance', + { code, lectureId, round }, + ); + return data?.success; }; export const updateAttendance = async (lectureId: number): Promise => { diff --git a/src/store/globalStore.ts b/src/store/globalStore.ts index f6a122cd..2144d4fc 100644 --- a/src/store/globalStore.ts +++ b/src/store/globalStore.ts @@ -1,9 +1,10 @@ -import { atom } from 'recoil'; +import { atom, DefaultValue, selector } from 'recoil'; export const user = atom({ key: 'userData', default: { id: 0, + adminStatus: 'NOT_CERTIFIED', name: '', }, }); diff --git a/src/styles/fonts.ts b/src/styles/fonts.ts deleted file mode 100644 index c5042fa1..00000000 --- a/src/styles/fonts.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { css } from '@emotion/react'; - -const display1 = css` - font-size: 2.4rem; - line-height: 3rem; - font-weight: 700; -`; -const display2 = css` - font-size: 2.4rem; - line-height: 3rem; - font-weight: 500; -`; - -const headline1 = css` - font-size: 2rem; - line-height: 2.5rem; - font-weight: 700; -`; -const headline2 = css` - font-size: 1.6rem; - line-height: 2rem; - font-weight: 700; -`; - -const body1 = css` - font-size: 1.6rem; - line-height: 2rem; - font-weight: 500; -`; -const body2 = css` - font-size: 1.4rem; - line-height: 2rem; - font-weight: 500; -`; - -const caption1 = css` - font-size: 1.2rem; - line-height: 1.5rem; - font-weight: 500; -`; -const caption2 = css` - font-size: 1rem; - line-height: 1.5rem; - font-weight: 500; -`; - -export { - body1, - body2, - caption1, - caption2, - display1, - display2, - headline1, - headline2, -}; diff --git a/src/styles/global.ts b/src/styles/global.ts index 4e5634bc..906e98d7 100644 --- a/src/styles/global.ts +++ b/src/styles/global.ts @@ -1,34 +1,55 @@ import { Interpolation } from '@emotion/react'; import { css, Theme } from '@emotion/react'; import { colors } from '@sopt-makers/colors'; +import { fontBase } from '@sopt-makers/fonts'; import emotionReset from 'emotion-reset'; const global: Interpolation = (theme: Theme) => css` ${emotionReset} * { - font-family: 'SUIT', sans-serif; - font-weight: 400; - font-style: normal; + ${fontBase} box-sizing: border-box; } html, body { + ${fontBase} font-size: 10px; width: 100%; min-height: 100%; - background-color: ${theme.color.grayscale.gray20}; - /* background-color: ${colors.background}; */ + + background-color: ${colors.background}; font-family: 'SUIT', sans-serif; font-weight: 400; font-style: normal; + + overflow-y: scroll; + overflow-x: hidden; + + &::-webkit-scrollbar { + width: 1.2rem; + } + &::-webkit-scrollbar-thumb { + background: ${colors.gray500}; + border-radius: 5px; + background-clip: padding-box; + border: 3px solid transparent; + } + ::-webkit-scrollbar-corner { + display: none; + } } #__next { width: 100%; min-height: 100%; + + div > .main-wrapper { + overflow: hidden; + } } button { border: none; + background: none; font-size: 10px; padding: 0; cursor: pointer; @@ -38,6 +59,13 @@ const global: Interpolation = (theme: Theme) => css` text-decoration: none; } + @font-face { + font-family: 'SUIT'; + src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_suit@1.0/SUIT-Light.woff2') + format('woff2'); + font-weight: 300; + font-style: normal; + } @font-face { font-family: 'SUIT'; src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_suit@1.0/SUIT-Regular.woff2') diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 44cd2026..194b2fa9 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -4,6 +4,14 @@ declare global { type PART = 'ALL' | 'PLAN' | 'DESIGN' | 'WEB' | 'ANDROID' | 'IOS' | 'SERVER'; type SESSION_TYPE = 'SEMINAR' | 'EVENT' | 'ETC'; type SESSION_STATUS = 'BEFORE' | 'FIRST' | 'SECOND' | 'END'; + type AlarmDropdownType = 'part' | 'target' | 'generation' | 'targetSelector'; + type ALARM_STATUS = '전체' | '발송 전' | '발송 후'; + type ADMIN_STATUS = + | 'SUPER_USER' + | 'SOPT' + | 'MAKERS' + | 'NOT_CERTIFIED' + | 'DEVELOPER'; /* 에러 */ interface LoginError { @@ -34,6 +42,7 @@ declare global { memberId: number; name: string; university: string; + part: string; }; updatedScore: number; } @@ -85,8 +94,10 @@ declare global { partValue: PART; partName: string; startDate: string; // yyyy/MM/dd + endDate: string; // yyyy/MM/dd attributeValue: SESSION_TYPE; attributeName: string; + place: string; attendances: { attendance: number; absent: number; @@ -136,6 +147,7 @@ declare global { /* 어드민 */ interface User { id: number; + adminStatus: ADMIN_STATUS; name: string; } @@ -181,5 +193,32 @@ declare global { interface ResponsePresignedUrl { presignedUrl: string; } + + /* 알림 */ + interface PostAlarmData { + attribute: string; + part: string | null; + isActive: boolean | null; + generationAt: number; + targetList: string[] | null; + title: string; + content: string; + link?: string | null; + } + interface Alarm { + alarmId: number; + part: string | null; + attribute: string; + title: string; + content: string; + sendAt: string; + status: string; + } + interface AlarmDetail + extends Omit { + createdAt: string; + sendAt: string; + } } + export default global; diff --git a/src/utils/alarm.ts b/src/utils/alarm.ts new file mode 100644 index 00000000..35a025d7 --- /dev/null +++ b/src/utils/alarm.ts @@ -0,0 +1,58 @@ +import { partList } from './session'; + +export const ALARM_TYPE = ['공지', '소식']; + +export const TARGET_USER_LIST = ['활동 회원', 'CSV 첨부']; +export const TARGET_GENERATION_LIST = [ + '전체 기수', + '33기', + '32기', + '31기', + '30기', +]; + +export const IS_FILE_UPLOAD_LIST = ['대상자 전체', '특정 유저 지정']; + +export const LINK_TYPE_LIST = [ + '첨부하지 않음', + '플레이그라운드 : 멤버', + '플레이그라운드 : 프로젝트', + '플레이그라운드 : 크루', + '공식 홈페이지', + '출석', + '솝탬프', +]; + +export const readPlaygroundId = async (file: File): Promise => { + return new Promise((resolve, reject) => { + const userIds: string[] = []; + const reader = new FileReader(); + let foundColumn = false; + + reader.readAsText(file, 'UTF-8'); + reader.onload = function (evt) { + try { + const csv = evt.target?.result as string; + const lines = csv.split('\n'); + + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes('[Amplitude] User ID')) { + foundColumn = true; + continue; + } + if (foundColumn) { + let value = lines[i].split(',')[0].trim(); + value = value.replace(/^"\t|\t"$|"/g, '').trim(); + if (value) userIds.push(value); + } + } + resolve(userIds); + } catch (error) { + reject(error); + } + }; + reader.onerror = function (error) { + reject(error); + }; + }); +}; diff --git a/src/utils/generation.ts b/src/utils/generation.ts index d9bedf8b..ba145b37 100644 --- a/src/utils/generation.ts +++ b/src/utils/generation.ts @@ -1,3 +1,3 @@ -export const ACTIVITY_GENRATION: string = '33'; +export const ACTIVITY_GENERATION: string = '33'; export const GENERATION_LIST: string[] = ['33', '32']; diff --git a/src/utils/nav.ts b/src/utils/nav.ts index a2ae8734..c866b6b2 100644 --- a/src/utils/nav.ts +++ b/src/utils/nav.ts @@ -1,12 +1,34 @@ +import { IcAlarmMenu, IcAttendanceMenu, IcOrgMenu } from '@/assets/icons'; +import { DoSoptLogo, GoSoptLogo } from '@/assets/icons/SoptLogos'; + +export const GENERATION_INFO = [ + { + generation: '33', + Logo: DoSoptLogo, + slogan: 'DO', + }, + { + generation: '32', + Logo: GoSoptLogo, + slogan: 'GO', + }, +]; + export const MENU_LIST = [ { title: '출석 관리', + MenuIcon: IcAttendanceMenu, subMenu: ['출석 세션', '출석 총점'], path: ['/attendanceAdmin/session', '/attendanceAdmin/totalScore'], }, { title: '공홈 관리', - subMenu: ['ABOUT'], + MenuIcon: IcOrgMenu, path: ['/orgAdmin/aboutTabManagement'], }, + { + title: '알림 관리', + MenuIcon: IcAlarmMenu, + path: ['/alarmAdmin'], + }, ]; diff --git a/src/utils/session.ts b/src/utils/session.ts index 703e53d2..1b6ee469 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -1,17 +1,4 @@ -export const sessionType = [ - { - session: '세미나', - desc: '미 출석시 출석점수 감점', - }, - { - session: '행사', - desc: '출석 시 0.5점 부여', - }, - { - session: '기타', - desc: '출석 점수 미반영', - }, -]; +export const attributeList: string[] = ['세미나', '행사', '기타']; export const sessionTranslator: Record = { 세미나: 'SEMINAR', diff --git a/src/utils/translator.ts b/src/utils/translator.ts index b6750d13..baecaf78 100644 --- a/src/utils/translator.ts +++ b/src/utils/translator.ts @@ -1,4 +1,4 @@ -import theme from '@/styles/theme'; +import { colors } from '@sopt-makers/colors'; export const partList: PART[] = [ 'ALL', @@ -9,34 +9,31 @@ export const partList: PART[] = [ 'ANDROID', 'WEB', ]; +export const allPartTranslator: Record = { + ALL: '전체 파트', + PLAN: '기획', + DESIGN: '디자인', + SERVER: '서버', + IOS: 'iOS', + ANDROID: '안드로이드', + WEB: '웹', +}; export const partTranslator: Record = { ALL: '전체', PLAN: '기획', DESIGN: '디자인', SERVER: '서버', IOS: 'iOS', - ANDROID: 'AOS', + ANDROID: '안드로이드', WEB: '웹', }; +export const attributeTranslator: Record = { + SEMINAR: '세미나', + EVENT: '행사', + ETC: '기타', +}; export const attendanceTranslator: Record = { ATTENDANCE: '출석', TARDY: '지각', ABSENT: '결석', }; -export const getAttendanceColor = ( - selected: ATTEND_STATUS | ATTEND_STATUS_KR, -) => { - switch (selected) { - case 'ABSENT': - case '결석': - return theme.color.sub.red; - case 'TARDY': - case '지각': - return theme.color.sub.yellow; - case 'ATTENDANCE': - case '출석': - return theme.color.sub.green; - default: - return theme.color.grayscale.black40; - } -}; diff --git a/yarn.lock b/yarn.lock index a2e49c86..c235143b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1922,10 +1922,15 @@ dependencies: "@sinonjs/commons" "^2.0.0" -"@sopt-makers/colors@^2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@sopt-makers/colors/-/colors-2.2.0.tgz#32be1c92c806d72f2e6893c094d446217ba3d7c8" - integrity sha512-L91wbWPxuLc5qTR+UJ1N69WzYqv35Z+jR5Yo3DhZHhzKFUwUrp9xOMVQd1ezS0RJKBXS60MsouMf3s2jA4ukug== +"@sopt-makers/colors@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@sopt-makers/colors/-/colors-3.0.0.tgz#a3db487bf645255cd25edcba49ba59e2cd54db05" + integrity sha512-1IWd4GUbkouKBqee+TgiRcRcaPB3pnBgjx9YN+Ti/G0pgFkdG+4K2VWKWrqUwIS/fV+fE8ttrjpYpgTWN+XhJw== + +"@sopt-makers/fonts@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@sopt-makers/fonts/-/fonts-1.0.0.tgz#50aefea7e5019fbd0dad1f8887cf0afe1c502505" + integrity sha512-a3LOO+DnHL9xZTOdqf8CA5YA4Q45wEIH6XMupHRptfdsWuw5L1bmxRUOGc6sGjjmZOn+aLUf/9+S0fOS112VGw== "@storybook/addon-actions@7.0.0-rc.4": version "7.0.0-rc.4"