diff --git a/.eslintrc.cjs b/.eslintrc.cjs index b3a49fc..ac9a8c6 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -27,6 +27,8 @@ module.exports = { rules: { "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "warn", + "@typescript-eslint/quotes": ["error", "single"], + "react/jsx-props-no-spreading": "off", "react/react-in-jsx-scope": "off", "react/no-unknown-property": ["error", { ignore: ["css"] }], "import/prefer-default-export": "off", diff --git a/.gitignore b/.gitignore index c32a56d..c2ee6c0 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,7 @@ node_modules .env.production.local .history /packages/*/bundles -/packages/*/lib/* \ No newline at end of file +/packages/*/lib/* + +# yarn berry +.yarn/install-state.gz \ No newline at end of file diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 1a3e947..9421f90 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/src/App.tsx b/src/App.tsx index 6a8ca4d..6d7dcf0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { Route, Routes } from 'react-router-dom'; import DefaultLayout from 'layout/DefaultLayout'; import MemberInfo from 'page/MemberInfo'; import SignUp from 'page/SignUp'; +import DuesManagement from 'page/DuesManagement'; function App() { return ( @@ -9,6 +10,8 @@ function App() { } /> } /> } /> + } /> + } /> ); } diff --git a/src/api/Dues/index.ts b/src/api/Dues/index.ts new file mode 100644 index 0000000..5d019ae --- /dev/null +++ b/src/api/Dues/index.ts @@ -0,0 +1,12 @@ +import { accessClient } from 'api'; +import { DuesInfo } from 'model/dues/allDues'; + +export interface DuesOptions { + year: number; + track?: string; +} + +export const getAllDues = ({ year, track }: DuesOptions) => { + const query = track ? `/dues?year=${year}&track=${track}` : `/dues?year=${year}`; + return accessClient.get(query); +}; diff --git a/src/layout/LoadingSpinner/index.tsx b/src/layout/LoadingSpinner/index.tsx new file mode 100644 index 0000000..f5d3c86 --- /dev/null +++ b/src/layout/LoadingSpinner/index.tsx @@ -0,0 +1,10 @@ +import { CircularProgress } from '@mui/material'; +import * as S from './style'; + +export default function LoadingSpinner() { + return ( + + + + ); +} diff --git a/src/layout/LoadingSpinner/style.ts b/src/layout/LoadingSpinner/style.ts new file mode 100644 index 0000000..b5efd89 --- /dev/null +++ b/src/layout/LoadingSpinner/style.ts @@ -0,0 +1,7 @@ +import { css } from '@emotion/react'; + +export const loading = css` + position: absolute; + top: 50%; + left: 50%; +`; diff --git a/src/model/dues/allDues.ts b/src/model/dues/allDues.ts new file mode 100644 index 0000000..7cc2940 --- /dev/null +++ b/src/model/dues/allDues.ts @@ -0,0 +1,23 @@ +export interface DuesInfo { + year: number; + dues: Dues[]; +} + +interface Dues { + memberId: number; + name: string; + track: TrackInfo; + unpaidCount: number; + detail: DuesDetail[]; +} + +export interface DuesDetail { + month: number; + status: '납부' | '면제' | '미납' | null; + memo?: string; +} + +interface TrackInfo { + id: number; + name: string; +} diff --git a/src/page/DuesManagement/index.tsx b/src/page/DuesManagement/index.tsx new file mode 100644 index 0000000..92ed533 --- /dev/null +++ b/src/page/DuesManagement/index.tsx @@ -0,0 +1,261 @@ +import { useNavigate } from 'react-router-dom'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import { + Button, Checkbox, FormControl, FormControlLabel, FormGroup, FormLabel, Input, Popover, +} from '@mui/material'; +import Modal from '@mui/material/Modal'; +import { + ChangeEvent, Suspense, useEffect, useState, +} from 'react'; +import { useGetAllDues } from 'query/dues'; +import useBooleanState from 'util/hooks/useBooleanState.ts'; +import { DuesDetail } from 'model/dues/allDues'; +import { STATUS_MAPPING } from 'util/constants/status'; +import { useGetTracks } from 'query/tracks'; +import LoadingSpinner from 'layout/LoadingSpinner'; +import { ArrowBackIosNewOutlined, ArrowForwardIosOutlined } from '@mui/icons-material'; +import useQueryParam from 'util/hooks/useQueryParam'; +import * as S from './style'; + +function DefaultTable() { + const navigate = useNavigate(); + const page = useQueryParam('page', 'number') as number; + const currentYear = new Date().getFullYear(); + const [duesYear, setDuesYear] = useState(currentYear - page + 1); + const [trackFilter, setTrackFilter] = useState([true, true, true, true, true, true]); + const [name, setName] = useState(''); + const [detail, setDetail] = useState({ month: 0, status: null }); + const [anchorEl, setAnchorEl] = useState(null); + const memoPopOverOpen = Boolean(anchorEl); + const { + value: isFilterModalOpen, + setTrue: openFilterModal, + setFalse: closeFilterModal, + } = useBooleanState(false); + + const { data: allDues } = useGetAllDues({ year: duesYear }); + const [filteredValue, setFilteredValue] = useState(allDues.dues); + + const { data: tracks } = useGetTracks(); + + const handleNameChange = (e: React.ChangeEvent) => { + const searchName = e.target.value; + if (searchName === '') { + if (trackFilter.every((value) => value)) { + setFilteredValue(allDues.dues); + } else { + setFilteredValue(allDues.dues.filter((row) => trackFilter[tracks.map((track) => track.name).indexOf(row.track.name)])); + } + } + setName(searchName); + }; + + const handleNameSearchClick = () => { + if (filteredValue.some((row) => row.name.includes(name))) { + setFilteredValue(allDues.dues.filter((row) => row.name.includes(name))); + } + }; + + const handleNameSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleNameSearchClick(); + } + }; + + const handleTrackFilterChange = (e: ChangeEvent) => { + const selectedTrack = e.target.name; + const trackIndex = tracks.filter((track) => track.name === selectedTrack)[0].id - 1; + setTrackFilter((prevTrack) => { + const updatedTrack = [...prevTrack]; + updatedTrack[trackIndex] = !updatedTrack[trackIndex]; + setFilteredValue(allDues.dues.filter( + (row) => updatedTrack[tracks.map((track) => track.name).indexOf(row.track.name)], + )); + return updatedTrack; + }); + }; + + const handleMemoClick = (e: React.MouseEvent, dueDetail: DuesDetail) => { + if (dueDetail.status === '미납' || dueDetail.status === '면제') { + setAnchorEl(e.currentTarget); + if (dueDetail.memo) { + setDetail(dueDetail); + } + } + }; + + useEffect(() => { + setDuesYear(currentYear - page + 1); + }, [currentYear, page]); + + const goToPrevYear = () => { + // 재학생 회비 내역이 2021년부터 시작하므로 2021년 이전으로 이동할 수 없음 + if (page < 4) { + const prevYear = page + 1; + navigate(`/dues?page=${prevYear}`); + } + }; + + const goToNextYear = () => { + if (page > 1) { + navigate(`/dues?page=${page - 1}`); + } + }; + return ( + <> + + + + + + {duesYear} + + + + + + + 검색 + + + + + + + + + + 트랙 + + 필터 + + + + + 트랙 선택 + + + + 원하는 트랙을 선택하세요. + + {tracks.map((track, index) => { + return ( + + } + label={track.name} + /> + ); + })} + + + + + + + + 미납 횟수 + 이름 + {Array.from({ length: 12 }, (_, i) => i + 1).map((month) => ( + + {month} + 월 + + ))} + + + + {filteredValue.map((row) => ( + + + {row.track.name} + + {row.unpaidCount} + {row.name} + {row.detail.map((dueDetail) => ( + handleMemoClick(e, dueDetail)} + key={dueDetail.month} + > + {/* TODO: detail.status에 따른 UI */} + {/* 미납 X(빨강), 면제 -(초록), 납부 O(초록), null -(default) */} + {dueDetail.status !== null ? STATUS_MAPPING[dueDetail.status] : '-'} + + ))} + + ))} + setAnchorEl(null)} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', + }} + > + + {/* TODO: 면제 혹은 미납의 구체적인 사유 */} + + {detail.status} + {' '} + 사유 + + {detail.memo} + + + + + + + > + ); +} + +export default function DuesManagement() { + const page = useQueryParam('page', 'number') as number; + const currentYear = new Date().getFullYear(); + const duesYear = currentYear - page + 1; + return ( + + + + 회원정보 + + + + + {duesYear} + 년 회비 내역 + + + + }> + + + + + + ); +} diff --git a/src/page/DuesManagement/style.ts b/src/page/DuesManagement/style.ts new file mode 100644 index 0000000..3813f99 --- /dev/null +++ b/src/page/DuesManagement/style.ts @@ -0,0 +1,163 @@ +import { css } from '@emotion/react'; +import { colors } from 'const/colors/style'; +import { DuesDetail } from 'model/dues/allDues'; + +export const sidebar = css` + display: flex; + flex-direction: column; + width: 250px; + min-height: calc(100vh - 10px); + background-color: ${colors.gray}; + border-right: 1px solid ${colors.borderGray}; +`; + +export const topBar = css` + width: 100%; + height: 100px; + background-color: ${colors.gray}; + border-bottom: 1px solid ${colors.borderGray}; + display: flex; + align-items: center; +`; + +export const topBarTitle = css` + margin-left: 16px; +`; + +export const content = css` + display: flex; + flex-direction: column; + align-items: center; + width: 100vw; +`; + +export const mainContent = css` + position: relative; + width: 95%; + min-height: calc(100vh - 250px); +`; + +export const container = css` + display: flex; + height: 100%; + width: 100%; + background-color: ${colors.gray}; +`; + +export const logo = css` + width: 100%; + margin-top: -10px; +`; + +export const searchAndPagination = css` + position: relative; + display: flex; + justify-content: end; + width: calc(100% - 100px); + margin: 10px 0; +`; + +export const pagination = css` + position: absolute; + right: 44%; + display: flex; +`; + +export const paginationTitle = css` + font-weight: bold; + font-size: 20px; + margin-top: 4px; +`; + +export const tableContainer = css` + overflow-x: initial; +`; + +export const tableHeader = css` + background-color: #1877f2; + color: #ffffff; +`; + +export const trackTableCell = css` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const dues = css` + width: calc(100% - 100px); + height: 100%; +`; + +export const filterModalButton = css` + padding: 8px 16px; + border-radius: 8px; + cursor: pointer; + border: 1px solid #eeeeee; + color: #212121; + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); +`; + +export const filterModal = css` + position: fixed; + z-index: 1300; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +`; + +export const filterModalContainer = css` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 400px; + display: flex; + flex-direction: column; + gap: 8px; + overflow: hidden; + background-color: #ffffff; + border-radius: 8px; + border: 1px solid #e0e0e0; + box-shadow: 0 4px 12px rgb(0 0 0 / 0.2); + padding: 24px; + color: #212121; +`; + +export const checkboxFieldset = css` + margin: 5px; +`; + +export const filterModalContent = css` + display: flex; + flex-direction: column; +`; + +// 미납 | 납부 | 면제 | null(아직 납부 달이 지나지 않음) +// popover를 여는 버튼은 미납, 면제일 때만 보이도록 함 +export const memoTableCell = (props: DuesDetail) => { + let backgroundColor = 'default'; + + switch (props.status) { + case '미납': + backgroundColor = '#ff5630'; + break; + case '면제': + case '납부': + backgroundColor = '#00a76f'; + break; + default: + backgroundColor = 'default'; + break; + } + + return css` + cursor: ${props.status === '미납' || props.status === '면제' ? 'pointer' : 'default'}; + background-color: ${backgroundColor}; + `; +}; + +export const memoPopover = css` + padding: 16px; +`; diff --git a/src/query/dues.ts b/src/query/dues.ts new file mode 100644 index 0000000..8127330 --- /dev/null +++ b/src/query/dues.ts @@ -0,0 +1,11 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { DuesOptions, getAllDues } from 'api/Dues'; +import { DuesInfo } from 'model/dues/allDues'; + +export const useGetAllDues = ({ year, track }: DuesOptions) => { + const { data }: { data: DuesInfo } = useSuspenseQuery({ + queryKey: ['dues', year, track], + queryFn: () => getAllDues({ year, track }), + }); + return { data }; +}; diff --git a/src/util/constants/status.ts b/src/util/constants/status.ts new file mode 100644 index 0000000..476f39c --- /dev/null +++ b/src/util/constants/status.ts @@ -0,0 +1,5 @@ +export const STATUS_MAPPING = { + 미납: 'X', + 면제: '-', + 납부: 'O', +}; diff --git a/src/util/hooks/useBooleanState.ts b/src/util/hooks/useBooleanState.ts new file mode 100644 index 0000000..92e2bd2 --- /dev/null +++ b/src/util/hooks/useBooleanState.ts @@ -0,0 +1,13 @@ +import { useCallback, useState } from 'react'; + +export default function useBooleanState(defaultValue?: boolean) { + const [value, setValue] = useState(!!defaultValue); + + const setTrue = useCallback(() => setValue(true), []); + const setFalse = useCallback(() => setValue(false), []); + const changeValue = useCallback(() => setValue((x) => !x), []); + + return { + value, setValue, setTrue, setFalse, changeValue, + } as const; +} diff --git a/src/util/hooks/useQueryParam.ts b/src/util/hooks/useQueryParam.ts new file mode 100644 index 0000000..f8fe21d --- /dev/null +++ b/src/util/hooks/useQueryParam.ts @@ -0,0 +1,24 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; + +export default function useQueryParam(key: string, type: string): string | number | null { + const location = useLocation(); + const [param, setParam] = useState(null); + + useEffect(() => { + const searchParams = new URLSearchParams(location.search); + const paramValue = searchParams.get(key); + if (paramValue) { + if (type === 'number') { + setParam(parseInt(paramValue, 10)); + } else { + setParam(paramValue); + } + } else { + setParam(null); + } + }, [location.search, key, type]); + + return param; +} diff --git a/tsconfig.json b/tsconfig.json index c45f05b..acc622d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", + "jsxImportSource": "@emotion/react", /* Linting */ "strict": true,