diff --git a/src/App.tsx b/src/App.tsx
index 984fef05..8294a28c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -12,6 +12,7 @@ import FindID from '@pages/Login/FindID';
import FindPWProcess from '@pages/Login/FindPWProcess';
import ReadyState from '@components/ReadyState';
import NotFound from '@components/NotFound';
+
import CotatoThemeProvider from '@theme/context/CotatoThemeProvider';
import GlobalBackgroundSvgComponent from '@components/GlobalBackgroundSvgComponent';
import { FAQ } from '@pages/FAQ';
@@ -23,6 +24,7 @@ import AgreementConfirmDialog from '@components/AgreementConfirmDialog';
import CSRoutes from '@pages/CS/CSRoutes';
import { About } from '@pages/About';
import 'react-toastify/dist/ReactToastify.css';
+import { ToastContainer } from 'react-toastify';
function App() {
const location = useLocation();
@@ -49,6 +51,7 @@ function App() {
+
diff --git a/src/assets/drop_box_background_yellow.svg b/src/assets/drop_box_background_yellow.svg
new file mode 100644
index 00000000..2b7f4217
--- /dev/null
+++ b/src/assets/drop_box_background_yellow.svg
@@ -0,0 +1,26 @@
+
diff --git a/src/assets/drop_box_background_yellow_lg.svg b/src/assets/drop_box_background_yellow_lg.svg
new file mode 100644
index 00000000..4f4cb8f1
--- /dev/null
+++ b/src/assets/drop_box_background_yellow_lg.svg
@@ -0,0 +1,26 @@
+
\ No newline at end of file
diff --git a/src/components/GenerationDropBox.tsx b/src/components/CotatoDropBox.tsx
similarity index 52%
rename from src/components/GenerationDropBox.tsx
rename to src/components/CotatoDropBox.tsx
index 01ee22ac..73de9935 100644
--- a/src/components/GenerationDropBox.tsx
+++ b/src/components/CotatoDropBox.tsx
@@ -1,24 +1,23 @@
import React, { useEffect, useRef, useState } from 'react';
import { styled, useTheme } from 'styled-components';
-import generationSort from '@utils/newGenerationSort';
-import { CotatoGenerationInfoResponse } from 'cotato-openapi-clients';
-import { DropBoxColorEnum } from '@/enums/DropBoxColor';
+import { CotatoGenerationInfoResponse, CotatoSessionListResponse } from 'cotato-openapi-clients';
import drop_box_background_blue from '@assets/drop_box_background_blue.svg';
-import { useNavigate, useSearchParams } from 'react-router-dom';
-import { useGeneration } from '@/hooks/useGeneration';
+import drop_box_background_yellow from '@assets/drop_box_background_yellow.svg';
+import drop_box_background_yellow_lg from '@assets/drop_box_background_yellow_lg.svg';
import CotatoIcon from './CotatoIcon';
//
//
//
-interface GenerationDropBoxProps {
- /**
- * generation change event
- * @param generation selected generation
- */
- handleGenerationChange: (generation: CotatoGenerationInfoResponse) => void;
- color?: DropBoxColorEnum;
+type CotatoDropBoxType = CotatoGenerationInfoResponse | CotatoSessionListResponse;
+
+interface CotatoDropBoxProps {
+ list: T[];
+ onChange: (item: T) => void;
+ reversed?: boolean;
+ defaultItemId?: number;
+ color?: string;
width?: string;
height?: string;
disableQueryParams?: boolean;
@@ -40,87 +39,109 @@ const FADE_DURATION = 300;
//
/**
- * generation drop box component
- * @param handleGenerationChange generation change event
+ * cotato drop box component
+ * @param list drop box list
+ * @param onChange list value change event
+ * @param reversed drop box list reversed (default: true)
* @param color drop box color (default: blue)
* @param width drop box width (default: 8rem)
* @param height drop box height (default: 3.2rem)
*/
-const GenerationDropBox = ({
- handleGenerationChange,
- color = DropBoxColorEnum.BLUE,
+const CotatoDropBox = ({
+ list,
+ onChange,
+ reversed = true,
+ defaultItemId,
+ color = 'blue',
width = '8rem',
height = '3.2rem',
- disableQueryParams = false,
-}: GenerationDropBoxProps) => {
+}: CotatoDropBoxProps) => {
const theme = useTheme();
- const navigate = useNavigate();
-
- const [searchParams, setSearchParams] = useSearchParams();
- const { generations: rawGenerations, isGenerationLoading } = useGeneration();
const [isDropBoxOpen, setIsDropBoxOpen] = useState(false);
- const [generations, setGenerations] = useState([]);
- const [selectedGeneration, setSelectedGeneration] = useState(
- null,
- );
+ const [dropBoxList, setDropBoxList] = useState([]);
+ const [selectedItem, setSelecedItem] = useState(null);
- const generationDropBoxRef = useRef(null);
+ const dropBoxRef = useRef(null);
- const isInProduction = process.env.NODE_ENV === 'production';
+ // const isInProduction = process.env.NODE_ENV === 'production';
/**
- * get drop box style of color
- * @returns drop box style { background: url of drop box background, arrowColor: color code of arrow button}
- * @throws invalid color type
+ *
*/
- const getDropBoxStyle = () => {
- if (color === DropBoxColorEnum.BLUE) {
- return {
- background: `url(${drop_box_background_blue})`,
- arrowColor: theme.colors.sub2[80],
- };
- }
+ const isTypeGeneration = (
+ generation: CotatoGenerationInfoResponse,
+ ): generation is CotatoGenerationInfoResponse => {
+ return (generation as CotatoGenerationInfoResponse).generationNumber !== undefined;
+ };
- throw new TypeError('invalid color type');
+ /**
+ *
+ */
+ const isTypeSession = (
+ session: CotatoSessionListResponse,
+ ): session is CotatoSessionListResponse => {
+ return (session as CotatoSessionListResponse).sessionNumber !== undefined;
};
/**
*
*/
- const setGenerationSearchParam = (generation: CotatoGenerationInfoResponse) => {
- if (disableQueryParams) {
- navigate(`/cs/${generation.generationId}`);
- return;
+ const StringFormatter = (item: T | null) => {
+ if (!item) {
+ return '';
+ }
+
+ if (isTypeGeneration(item)) {
+ return `${item.generationNumber}기`;
}
- if (generation?.generationId) {
- setSearchParams({ generationId: generation.generationId.toString() });
+ if (isTypeSession(item)) {
+ return `${item.title}`;
}
+
+ return '';
};
/**
- *
+ * get drop box style of color
+ * @returns drop box style { background: url of drop box background, arrowColor: color code of arrow button}
*/
- const handleDropDownChange = () => {
- setIsDropBoxOpen(!isDropBoxOpen);
+ const getDropBoxStyle = () => {
+ if (color === 'blue') {
+ return {
+ background: `url(${drop_box_background_blue})`,
+ arrowColor: theme.colors.sub2[80],
+ };
+ }
+
+ if (color === 'yellow') {
+ return {
+ background: `url(${width === '12rem' ? drop_box_background_yellow_lg : drop_box_background_yellow})`,
+ arrowColor: theme.colors.primary40,
+ };
+ }
+
+ return {
+ background: `url(${drop_box_background_blue})`,
+ arrowColor: theme.colors.sub2[80],
+ };
};
/**
*
*/
- const handleGenerationSelect = (generation: CotatoGenerationInfoResponse) => {
- setSelectedGeneration(generation);
- handleGenerationChange(generation);
- setGenerationSearchParam(generation);
+ const handleDropDownChange = () => {
+ setIsDropBoxOpen(!isDropBoxOpen);
};
/**
*
*/
- const handleGenerationClick = (generation: CotatoGenerationInfoResponse) => {
+ const handleItemClick = (generation: T) => {
handleDropDownChange();
- handleGenerationSelect(generation);
+ setSelecedItem(generation);
+ onChange(generation);
};
/**
@@ -131,10 +152,7 @@ const GenerationDropBox = ({
return (
-
- {selectedGeneration?.generationNumber}
- {selectedGeneration && '기'}
-
+ {StringFormatter(selectedItem)}
{isDropBoxOpen ? (
) : (
@@ -151,24 +169,18 @@ const GenerationDropBox = ({
return (
- {generations
- .filter(
- (generation) =>
- !isInProduction ||
- (generation?.generationNumber && generation.generationNumber >= 8),
- )
- .map((generation) => (
- - handleGenerationClick(generation)}
- >
- {generation === selectedGeneration && (
- theme.colors.sub3[40]} />
- )}
- {generation.generationNumber}기
-
- ))}
+ {dropBoxList.map((item, index) => (
+ - handleItemClick(item)}
+ >
+ {item === selectedItem && (
+
+ )}
+ {StringFormatter(item)}
+
+ ))}
);
@@ -178,42 +190,47 @@ const GenerationDropBox = ({
*
*/
useEffect(() => {
- window.addEventListener('mousedown', (e) => {
- if (
- generationDropBoxRef.current &&
- !generationDropBoxRef.current.contains(e.target as Node) &&
- isDropBoxOpen
- ) {
- handleDropDownChange();
- }
- });
- return () => window.removeEventListener('mousedown', () => {});
- }, [generationDropBoxRef, isDropBoxOpen]);
+ let newList = [...list];
- /**
- *
- */
- useEffect(() => {
- if (!rawGenerations || isGenerationLoading) {
- return;
+ if (reversed) {
+ newList = [...newList].reverse();
}
- const sortedGenerations = generationSort(rawGenerations);
- // .filter(
- // (generation) => generation.generationNumber && generation.generationNumber >= 8,
- // );
- setGenerations(sortedGenerations);
+ setDropBoxList(newList);
- const generationId = searchParams.get('generationId');
- const searchedGeneration = sortedGenerations.find(
- (generation) => generation.generationId === Number(generationId),
- );
+ if (defaultItemId) {
+ const defaultItem = newList.find((item) => {
+ if (isTypeGeneration(item)) {
+ return item.generationId === defaultItemId;
+ }
+
+ if (isTypeSession(item)) {
+ return item.sessionId === defaultItemId;
+ }
+
+ return false;
+ });
- handleGenerationSelect(searchedGeneration || sortedGenerations[0]);
- }, [rawGenerations, isGenerationLoading]);
+ setSelecedItem(defaultItem ?? newList[0]);
+ } else {
+ setSelecedItem(newList[0]);
+ }
+ }, [list, defaultItemId]);
+
+ /**
+ *
+ */
+ useEffect(() => {
+ window.addEventListener('mousedown', (e) => {
+ if (dropBoxRef.current && !dropBoxRef.current.contains(e.target as Node) && isDropBoxOpen) {
+ handleDropDownChange();
+ }
+ });
+ return () => window.removeEventListener('mousedown', () => {});
+ }, [dropBoxRef, isDropBoxOpen]);
return (
-
+
{renderDropBox()}
{renderDropDownList()}
@@ -248,6 +265,9 @@ const SelectText = styled.span`
font-family: Ycomputer;
color: ${({ theme }) => theme.colors.gray100};
font-size: ${({ theme }) => theme.size.lg};
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
`;
const StyledCotatoIcon = styled(CotatoIcon)`
@@ -306,6 +326,9 @@ const DropDownList = styled.div`
font-family: Ycomputer;
font-size: ${({ theme }) => theme.size.lg};
line-height: 3rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
&.selected {
background-color: ${({ theme }) => theme.colors.gray10};
@@ -320,4 +343,4 @@ const StyledCheckIcon = styled(CotatoIcon)`
top: 0.75rem;
`;
-export default GenerationDropBox;
+export default CotatoDropBox;
diff --git a/src/components/CotatoTextField/CotatoTextField.tsx b/src/components/CotatoTextField/CotatoTextField.tsx
new file mode 100644
index 00000000..14b9c982
--- /dev/null
+++ b/src/components/CotatoTextField/CotatoTextField.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import CotatoIcon from '@components/CotatoIcon';
+import { Box, IconButton, TextField, TextFieldProps } from '@mui/material';
+import { useTheme } from 'styled-components';
+
+//
+//
+//
+
+const CotatoTextField = ({ ...props }: TextFieldProps) => {
+ const theme = useTheme();
+
+ /**
+ *
+ */
+ const handleClear = () => {
+ const event = {
+ target: {
+ value: '',
+ },
+ } as React.ChangeEvent;
+ props.onChange?.(event);
+ };
+
+ //
+ //
+ //
+
+ return (
+
+ theme.colors.common.black}
+ size="1.25rem"
+ />
+
+ ),
+ endAdornment: props.value ? (
+
+ theme.colors.common.black} size="1rem" />
+
+ ) : null,
+ },
+ }}
+ sx={{
+ '& .MuiInputBase-input': {
+ color: theme.colors.common.black,
+ },
+ gap: '0.5rem',
+ '& .MuiInputBase-root': {
+ gap: '0.5rem',
+ },
+ '& .MuiInput-underline:before': {
+ borderBottomColor: theme.colors.common.black,
+ },
+ '& .MuiInput-underline:hover:not(.Mui-disabled):before': {
+ borderBottomColor: theme.colors.primary60,
+ },
+ '& .MuiInput-underline:after': {
+ borderBottomColor: theme.colors.primary100,
+ },
+ }}
+ {...props}
+ />
+ );
+};
+
+export default CotatoTextField;
diff --git a/src/enums/DropBoxColor.ts b/src/enums/DropBoxColor.ts
deleted file mode 100644
index 1bf97ffb..00000000
--- a/src/enums/DropBoxColor.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/**
- * enum for DropBoxColor
- *
- * - **BLUE**
- */
-export enum DropBoxColorEnum {
- BLUE = 'blue',
-}
diff --git a/src/hooks/useSession.ts b/src/hooks/useSession.ts
index ead5e6a0..324a462b 100644
--- a/src/hooks/useSession.ts
+++ b/src/hooks/useSession.ts
@@ -10,10 +10,12 @@ import useSWR from 'swr';
interface UseSessionProps {
generationId?: number;
+ sessionId?: number;
}
interface UseSessionReturn {
sessions: CotatoSessionListResponse[] | undefined;
+ targetSession: CotatoSessionListResponse | undefined;
isSessionLoading: boolean;
isSessionError: any;
}
@@ -22,7 +24,7 @@ interface UseSessionReturn {
//
//
-export function useSession({ generationId }: UseSessionProps) {
+export function useSession({ generationId, sessionId }: UseSessionProps) {
const _return = useRef({} as UseSessionReturn);
const { data, isLoading, error } = useSWR(
@@ -35,8 +37,11 @@ export function useSession({ generationId }: UseSessionProps) {
},
);
+ _return.current.targetSession = data?.find((session) => session.sessionId === sessionId);
+
_return.current = {
sessions: data || [],
+ targetSession: _return.current.targetSession,
isSessionLoading: isLoading,
isSessionError: error,
};
diff --git a/src/pages/Attendance/Attendance.routes.tsx b/src/pages/Attendance/Attendance.routes.tsx
index 78a9f657..f1a3bdfa 100644
--- a/src/pages/Attendance/Attendance.routes.tsx
+++ b/src/pages/Attendance/Attendance.routes.tsx
@@ -10,6 +10,7 @@ const AsyncAttendanceAttend = React.lazy(() => import('./Attend/AttendanceAttend
const AsyncAttendanceList = React.lazy(() => import('./List/AttendanceList'));
const AsyncAttendanceReport = React.lazy(() => import('./Report/AttendanceReport'));
const AsyncAttendanceResult = React.lazy(() => import('./Attend/AttendanceAttendResult'));
+const AsyncAttendanceReportAll = React.lazy(() => import('./Report/AttendanceReportAll'));
//
//
@@ -29,10 +30,14 @@ const AttendanceRoutes = () => {
element={}
/>
}
/>
- } />
+ }
+ />
+ } />
} />
diff --git a/src/pages/Attendance/List/AttendanceList.tsx b/src/pages/Attendance/List/AttendanceList.tsx
index 1496cbb0..1984202b 100644
--- a/src/pages/Attendance/List/AttendanceList.tsx
+++ b/src/pages/Attendance/List/AttendanceList.tsx
@@ -67,8 +67,9 @@ const AttendanceList = () => {
*
*/
const handleClickReport = () => {
- const currentMonth = new Date().getMonth() + 1;
- navigate(`/attendance/report/${generationId}/month/${currentMonth}`);
+ navigate(
+ `/attendance/report/generation/${generationId}/session/${attendanceList.at(-1)?.sessionId}`,
+ );
};
/**
diff --git a/src/pages/Attendance/Report/AttendanceReport.tsx b/src/pages/Attendance/Report/AttendanceReport.tsx
index bfcc3748..db9241ce 100644
--- a/src/pages/Attendance/Report/AttendanceReport.tsx
+++ b/src/pages/Attendance/Report/AttendanceReport.tsx
@@ -1,124 +1,15 @@
import React from 'react';
-import {
- Paper,
- Table,
- TableBody,
- TableCell,
- TableContainer,
- TableHead,
- TableRow,
- Typography,
-} from '@mui/material';
import { Container, Stack } from '@mui/system';
-import fetcherWithParams from '@utils/fetcherWithParams';
-import { CotatoAttendanceRecordResponse, CotatoAttendanceStatistic } from 'cotato-openapi-clients';
-
-import { useParams } from 'react-router-dom';
-import useSWR from 'swr';
-import fetcher from '@utils/fetcher';
-import useGetAttendances from '@/hooks/useGetAttendances';
-import { useSession } from '@/hooks/useSession';
-
-//
-//
-//
-
-const STATISTICS_MAP = {
- offline: '대면',
- online: '비대면',
- late: '지각',
- absent: '결석',
-} as unknown as CotatoAttendanceStatistic;
+import AttendanceReportSubFilters from './AttendanceReportSubFilters';
+import AttendanceReportHeader from './AttendanceReportHeader';
+import AttendanceReportTable from './AttendanceReportTable';
//
//
//
const AttendanceReport = () => {
- const { generationId, month } = useParams();
-
- const { sessions } = useSession({ generationId: Number(generationId) });
- const latestSession = sessions?.at(-1);
-
- const { currentAttendance } = useGetAttendances({
- generationId: Number(generationId),
- sessionId: latestSession?.sessionId,
- });
-
- // data for month
- const { data: monthRecords } = useSWR(
- '/v2/api/attendances/records',
- (url: string) =>
- fetcherWithParams(url, {
- generationId,
- month,
- }),
- {
- revalidateOnFocus: false,
- },
- );
-
- // data for latest session
- const { data: currentRecord } = useSWR(
- `/v2/api/attendances/${currentAttendance?.attendanceId}/records`,
- fetcher,
- {
- revalidateOnFocus: false,
- },
- );
-
- /**
- * Get current statistic
- */
- const getCurrentStatistic = (report: CotatoAttendanceRecordResponse) => {
- const currentStatistics = currentRecord?.find(
- (record) => record.memberInfo?.memberId === report.memberInfo?.memberId,
- )?.statistic;
-
- // online, offline, late, absent
- const keys = Object.keys(currentStatistics || {});
-
- // 0이 아닌 값이 있는 인덱스 == online, offline, late, absent 중 하나
- const currentInfoIndex = Object.values(currentStatistics || {}).findIndex(
- (value) => value !== 0,
- );
-
- const currentStatistic = keys[currentInfoIndex] as keyof CotatoAttendanceStatistic;
-
- return currentStatistic;
- };
-
- /**
- *
- */
- const renderTableHead = () => {
- return (
-
-
-
- 멤버
-
-
- 최근 세션
-
-
- 대면
-
-
- 비대면
-
-
- 지각
-
-
- 결석
-
-
-
- );
- };
-
//
//
//
@@ -129,60 +20,11 @@ const AttendanceReport = () => {
height: '100%',
}}
>
-
-
-
- 임시 출석 현황
-
-
-
-
- {renderTableHead()}
-
- {monthRecords?.map((report) => {
- const currentStatistic = getCurrentStatistic(report);
-
- return (
-
- {/* 멤버 */}
-
- {report.memberInfo?.name}
-
-
- {/* 최근 세션 */}
-
-
- {typeof currentStatistic === 'string'
- ? STATISTICS_MAP[currentStatistic]
- : '--'}
-
-
-
- {/* 대면 */}
-
- {report.statistic?.offline}
-
-
- {/* 비대면 */}
-
- {report.statistic?.online}
-
-
- {/* 지각 */}
- {report.statistic?.late}
-
- {/* 결석 */}
- {report.statistic?.online}
-
- );
- })}
-
-
-
+
+ {/* */}
+
+
+
);
diff --git a/src/pages/Attendance/Report/AttendanceReportAll.tsx b/src/pages/Attendance/Report/AttendanceReportAll.tsx
new file mode 100644
index 00000000..f3a5c354
--- /dev/null
+++ b/src/pages/Attendance/Report/AttendanceReportAll.tsx
@@ -0,0 +1,25 @@
+import { Container, Stack } from '@mui/material';
+import React from 'react';
+import AttendanceReportSubFilters from './AttendanceReportSubFilters';
+import AttendanceReportAllTable from './AttendanceReportAllTable';
+
+//
+//
+//
+
+const AttendanceReportAll = () => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default AttendanceReportAll;
diff --git a/src/pages/Attendance/Report/AttendanceReportAllTable.tsx b/src/pages/Attendance/Report/AttendanceReportAllTable.tsx
new file mode 100644
index 00000000..286a2439
--- /dev/null
+++ b/src/pages/Attendance/Report/AttendanceReportAllTable.tsx
@@ -0,0 +1,120 @@
+import { Stack, Table, TableBody, TableRow } from '@mui/material';
+import React, { useEffect } from 'react';
+import { useParams, useSearchParams } from 'react-router-dom';
+
+import { STATUS_ASSETS } from '../constants';
+import { useAttendancesRecords } from '../hooks/useAttendancesRecords';
+
+import AttedanceTableLayout from './components/AttedanceTableLayout';
+
+//
+//
+//
+
+const NAME_FIELD = [
+ { icon: null, text: '이름', status: 'name' },
+] as unknown as typeof STATUS_ASSETS;
+
+const TABLE_HEAD = NAME_FIELD.concat(STATUS_ASSETS);
+
+//
+//
+//
+
+const AttendanceReportAllTable = () => {
+ //
+ const { generationId } = useParams<{ generationId: string }>();
+
+ //
+ const [searchParams] = useSearchParams();
+ const search = searchParams.get('search') || '';
+
+ //
+ const { attendancesRecords, filteredAttendancesRecords } = useAttendancesRecords({
+ generationId,
+ name: search,
+ });
+
+ //
+ const [currentRecords, setCurrentRecords] = React.useState(attendancesRecords);
+
+ /**
+ *
+ */
+ const renderTableHead = () => {
+ return (
+
+
+ {TABLE_HEAD.map((head) => (
+
+
+ {head.icon}
+ {head.text}
+
+
+ ))}
+
+
+ );
+ };
+
+ /**
+ *
+ */
+ const renderTableBody = () => {
+ return (
+
+ {currentRecords?.map((record) => (
+
+
+ {record.memberInfo?.name}
+
+
+
+ {record.statistic?.online}
+
+
+
+ {record.statistic?.offline}
+
+
+
+ {record.statistic?.late}
+
+
+
+ {record.statistic?.absent}
+
+
+ ))}
+
+ );
+ };
+
+ //
+ // if search exists, set filtered records
+ //
+ useEffect(() => {
+ if (search) {
+ setCurrentRecords(filteredAttendancesRecords);
+ return;
+ }
+
+ setCurrentRecords(attendancesRecords);
+ }, [search, attendancesRecords, filteredAttendancesRecords]);
+
+ //
+ //
+ //
+
+ return (
+
+
+ {renderTableHead()}
+ {renderTableBody()}
+
+
+ );
+};
+
+export default AttendanceReportAllTable;
diff --git a/src/pages/Attendance/Report/AttendanceReportHeader.tsx b/src/pages/Attendance/Report/AttendanceReportHeader.tsx
new file mode 100644
index 00000000..1c0c025e
--- /dev/null
+++ b/src/pages/Attendance/Report/AttendanceReportHeader.tsx
@@ -0,0 +1,151 @@
+import React from 'react';
+import { Box, Button, Stack, Typography } from '@mui/material';
+import styled, { useTheme } from 'styled-components';
+import { useGeneration } from '@/hooks/useGeneration';
+import CotatoDropBox from '@components/CotatoDropBox';
+import { CotatoGenerationInfoResponse, CotatoSessionListResponse } from 'cotato-openapi-clients';
+import { useNavigate, useParams } from 'react-router-dom';
+import { useSession } from '@/hooks/useSession';
+import CotatoIcon from '@components/CotatoIcon';
+import { useBreakpoints } from '@/hooks/useBreakpoints';
+
+//
+//
+//
+
+const AttendanceReportHeader = () => {
+ const theme = useTheme();
+ const navigate = useNavigate();
+ const { isLandScapeOrSmaller } = useBreakpoints();
+
+ const { generationId } = useParams();
+ const { sessionId } = useParams();
+
+ const { generations, targetGeneration } = useGeneration({
+ generationId: generationId,
+ });
+ const { sessions, targetSession } = useSession({
+ generationId: Number(generationId),
+ sessionId: Number(sessionId),
+ });
+
+ /**
+ *
+ */
+ const handlePreviousClick = () => {
+ navigate('/attendance');
+ };
+
+ /**
+ *
+ */
+ const handleGenerationChange = (generations: CotatoGenerationInfoResponse) => {
+ navigate(`/attendance/report/generation/${generations.generationId}/session/${sessionId}`);
+ };
+
+ /**
+ *
+ */
+ const handleSessionChange = (session: CotatoSessionListResponse) => {
+ navigate(`/attendance/report/generation/${generationId}/session/${session.sessionId}`);
+ };
+
+ /**
+ *
+ */
+ const handleExportExcelClick = () => {
+ alert('출시 예정입니다 :)');
+ };
+
+ return (
+
+
+
+
+ 출석부 확인하기
+
+
+
+
+ {generations && (
+
+ )}
+ {sessions && (
+
+ )}
+
+
+
+ }
+ sx={{
+ backgroundColor: theme.colors.primary80,
+ borderRadius: '0.325rem',
+ }}
+ >
+
+ {!isLandScapeOrSmaller && '엑셀로 내보내기'}
+
+
+
+
+
+ );
+};
+
+export default AttendanceReportHeader;
+
+//
+//
+//
+
+const StyledIcon = styled(CotatoIcon)`
+ position: absolute;
+ top: 50%;
+ left: 0;
+ transform: translateX(-50%);
+ rotate: 90deg;
+ width: 2rem !important;
+ height: 2rem !important;
+ cursor: pointer;
+`;
diff --git a/src/pages/Attendance/Report/AttendanceReportSearch.tsx b/src/pages/Attendance/Report/AttendanceReportSearch.tsx
new file mode 100644
index 00000000..ae93243f
--- /dev/null
+++ b/src/pages/Attendance/Report/AttendanceReportSearch.tsx
@@ -0,0 +1,62 @@
+import React, { useEffect } from 'react';
+import CotatoTextField from '@components/CotatoTextField/CotatoTextField';
+import { Box } from '@mui/material';
+import { useDebounce } from 'react-use';
+import { useAttendanceReportFilter } from '../hooks/useAttendanceReportFilter';
+
+//
+//
+//
+
+const DEBOUNCE_TIME = 500;
+
+//
+//
+//
+
+const AttendanceReportSearch = () => {
+ //
+ const { updateSearchParams } = useAttendanceReportFilter();
+
+ //
+ const [searchValue, setSearchValue] = React.useState('');
+ const [debouncedSearchValue, setDebouncedSearchValue] = React.useState('');
+
+ //
+ //
+ //
+ useDebounce(
+ () => {
+ setDebouncedSearchValue(searchValue);
+ },
+ DEBOUNCE_TIME,
+ [searchValue],
+ );
+
+ //
+ //
+ //
+ useEffect(() => {
+ updateSearchParams(debouncedSearchValue);
+ }, [debouncedSearchValue]);
+
+ //
+ //
+ //
+
+ return (
+
+ {
+ setSearchValue(e.target.value);
+ }}
+ />
+
+ );
+};
+
+export default AttendanceReportSearch;
diff --git a/src/pages/Attendance/Report/AttendanceReportSubFilterActions.tsx b/src/pages/Attendance/Report/AttendanceReportSubFilterActions.tsx
new file mode 100644
index 00000000..d7b5edf7
--- /dev/null
+++ b/src/pages/Attendance/Report/AttendanceReportSubFilterActions.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+
+import { Stack, Typography } from '@mui/material';
+
+import styled from 'styled-components';
+import { useAttendanceReportFilter } from '../hooks/useAttendanceReportFilter';
+import { useMatch } from 'react-router-dom';
+import { STATUS_ASSETS } from '../constants';
+
+//
+//
+//
+
+const AttendanceReportSubFilterActions = () => {
+ //
+ const { currentStatus, toggleStatus } = useAttendanceReportFilter();
+
+ /**
+ *
+ */
+ const checkIsStatusSelected = (status: string) => {
+ return currentStatus.includes(status);
+ };
+
+ //
+ const match = useMatch('/attendance/report/generation/:generationId/all');
+
+ //
+ const disabled = match ? true : false;
+
+ //
+ //
+ //
+ return (
+
+ {STATUS_ASSETS.map(({ status, icon, text }) => (
+ {
+ toggleStatus(status);
+ }}
+ >
+ {icon}
+ {text}
+
+ ))}
+
+ );
+};
+
+export default AttendanceReportSubFilterActions;
+
+//
+//
+//
+
+const StyledButton = styled.button<{ $selected?: boolean; $disabled?: boolean }>`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 8rem;
+ height: 2.75rem;
+ gap: 0.75rem;
+ outline: none;
+ border: none;
+ border-radius: 0.5rem;
+ background-color: ${({ theme, $selected }) =>
+ $selected ? theme.colors.primary80 : theme.colors.gray10};
+
+ // disabled
+ background-color: ${({ theme, $disabled }) => $disabled && theme.colors.gray30};
+
+ &:hover {
+ transition: background-color 0.3s;
+ ${({ theme, $selected }) => !$selected && `background-color: ${theme.colors.gray20};`}
+
+ // disabled
+ ${({ theme, $disabled }) => $disabled && `background-color: ${theme.colors.gray30};`}
+ }
+
+ opacity: ${({ $disabled }) => ($disabled ? 0.8 : 1)};
+ cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')};
+`;
+
+const StyledTypography = styled(Typography)<{ $disabled?: boolean }>`
+ color: ${({ theme }) => theme.colors.common.black_const};
+ color: ${({ theme, $disabled }) =>
+ $disabled ? theme.colors.gray10 : theme.colors.common.black_const};
+`;
diff --git a/src/pages/Attendance/Report/AttendanceReportSubFilters.tsx b/src/pages/Attendance/Report/AttendanceReportSubFilters.tsx
new file mode 100644
index 00000000..c7ec3533
--- /dev/null
+++ b/src/pages/Attendance/Report/AttendanceReportSubFilters.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { Stack } from '@mui/material';
+import AttendanceReportSubFilterActions from './AttendanceReportSubFilterActions';
+import AttendanceReportSearch from './AttendanceReportSearch';
+
+//
+//
+//
+
+const AttendanceReportSubFilters = () => {
+ //
+ //
+ //
+ return (
+
+
+
+
+ );
+};
+
+export default AttendanceReportSubFilters;
diff --git a/src/pages/Attendance/Report/AttendanceReportTable.tsx b/src/pages/Attendance/Report/AttendanceReportTable.tsx
new file mode 100644
index 00000000..55c4d7e0
--- /dev/null
+++ b/src/pages/Attendance/Report/AttendanceReportTable.tsx
@@ -0,0 +1,211 @@
+import useGetAttendances from '@/hooks/useGetAttendances';
+import { Table, TableBody, TableCell, TableRow } from '@mui/material';
+import React, { useEffect } from 'react';
+import { useAttendancesAttendanceIdRecordsGet } from '../hooks/useAttendancesAttendanceIdRecordsGet';
+import { useBreakpoints } from '@/hooks/useBreakpoints';
+import AttedanceTableLayout from './components/AttedanceTableLayout';
+import { getCurrentStatistic } from '../utils/util';
+import AttendanceStatusDropdown from './components/AttendanceStatusDropdown';
+import { useParams, useSearchParams } from 'react-router-dom';
+import { v4 as uuid } from 'uuid';
+
+//
+//
+//
+
+const TABLE_HEAD = ['이름', '출석 상태'];
+
+//
+//
+//
+
+const AttendanceReportTable = () => {
+ const { isMobileOrSmaller } = useBreakpoints();
+
+ const [searchParams] = useSearchParams();
+ const status = searchParams.getAll('status') ?? [];
+ const search = searchParams.get('search') || '';
+
+ //
+ const { generationId } = useParams();
+ const { sessionId } = useParams();
+
+ //
+ const { currentAttendance } = useGetAttendances({
+ sessionId: Number(sessionId),
+ generationId: Number(generationId),
+ });
+
+ //
+ const attendanceId = currentAttendance?.attendanceId;
+
+ //
+ const { attendancesAttendanceIdRecords } = useAttendancesAttendanceIdRecordsGet({
+ attendanceId: attendanceId ?? 0,
+ });
+
+ //
+ const [currentRecords, setCurrentRecords] = React.useState(attendancesAttendanceIdRecords);
+
+ /**
+ *
+ */
+ const renderTableHead = () => {
+ if (isMobileOrSmaller) {
+ return (
+
+ {TABLE_HEAD.map((head) => (
+
+ {head}
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {TABLE_HEAD.map((head) => (
+ <>
+
+ {head}
+
+ >
+ ))}
+ {TABLE_HEAD.map((head) => (
+ <>
+
+ {head}
+
+ >
+ ))}
+
+ );
+ };
+
+ /**
+ *
+ */
+ const renderEmptyTableCell = () => {
+ return (
+ <>
+
+
+ >
+ );
+ };
+
+ /**
+ *
+ */
+ const renderTableBody = () => {
+ if (isMobileOrSmaller) {
+ return currentRecords?.map((record) => (
+ <>
+
+
+ {record.memberInfo?.name}
+
+
+
+
+
+ >
+ ));
+ }
+
+ return Array.from({ length: Math.ceil(currentRecords?.length / 2) }, (_, i) => {
+ const firstRecord = currentRecords[i * 2];
+ const secondRecord = currentRecords?.[i * 2 + 1];
+
+ return (
+
+
+ {firstRecord?.memberInfo?.name}
+
+
+
+
+ {secondRecord ? (
+ <>
+
+ {secondRecord?.memberInfo?.name}
+
+
+
+
+ >
+ ) : (
+ renderEmptyTableCell()
+ )}
+
+ );
+ });
+ };
+
+ //
+ //
+ //
+ useEffect(() => {
+ if (!attendancesAttendanceIdRecords) {
+ return;
+ }
+
+ let filteredRecords = attendancesAttendanceIdRecords;
+
+ // filter records by search
+ if (search) {
+ filteredRecords = filteredRecords?.filter((record) =>
+ record.memberInfo?.name?.toLowerCase().includes(search.toLowerCase()),
+ );
+
+ console.log(filteredRecords);
+
+ setCurrentRecords(filteredRecords);
+ }
+
+ // filter records by status
+ if (status.length) {
+ filteredRecords = filteredRecords?.filter((record) =>
+ status.some((status) =>
+ getCurrentStatistic(record).toLowerCase().includes(status.toLowerCase()),
+ ),
+ );
+
+ setCurrentRecords(filteredRecords);
+
+ return;
+ }
+
+ if (!search && !status.length) {
+ setCurrentRecords(attendancesAttendanceIdRecords);
+ }
+ }, [status.length, attendancesAttendanceIdRecords, search]);
+
+ //
+ //
+ //
+
+ return (
+
+
+ {renderTableHead()}
+ {renderTableBody()}
+
+
+ );
+};
+
+export default AttendanceReportTable;
diff --git a/src/pages/Attendance/Report/components/AttedanceTableLayout.tsx b/src/pages/Attendance/Report/components/AttedanceTableLayout.tsx
new file mode 100644
index 00000000..78719aaa
--- /dev/null
+++ b/src/pages/Attendance/Report/components/AttedanceTableLayout.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { TableCell, TableCellProps, TableContainer, TableHead, TableRow } from '@mui/material';
+import styled from 'styled-components';
+
+//
+//
+//
+
+const StyledTableContainer = styled(TableContainer)`
+ border: 1px solid ${({ theme }) => theme.colors.primary60};
+`;
+
+const StyledTableHead = styled(TableHead)`
+ background-color: ${({ theme }) => theme.colors.pastelTone.yellow[100]};
+`;
+
+const StyledTableRow = styled(TableRow)`
+ background-color: ${({ theme }) => theme.colors.common.white_const};
+`;
+
+/**
+ *
+ */
+const CenterAlignedTableCell: React.FC = ({ children, ...props }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+const StyledTableCell = styled(CenterAlignedTableCell)`
+ width: 20%;
+ border-bottom: 1px solid ${({ theme }) => theme.colors.primary60} !important;
+`;
+
+export default Object.assign(
+ {},
+ {
+ TableContainer: StyledTableContainer,
+ TableHead: StyledTableHead,
+ TableRow: StyledTableRow,
+ TableCell: StyledTableCell,
+ TableHeadTableCell: StyledTableCell,
+ },
+);
diff --git a/src/pages/Attendance/Report/components/AttendanceStatusDropdown.tsx b/src/pages/Attendance/Report/components/AttendanceStatusDropdown.tsx
new file mode 100644
index 00000000..68beb2b9
--- /dev/null
+++ b/src/pages/Attendance/Report/components/AttendanceStatusDropdown.tsx
@@ -0,0 +1,128 @@
+import api from '@/api/api';
+import { MenuItem, Stack, TextField } from '@mui/material';
+import { ATTENDANCE_ASSETS_MAP } from '@pages/Attendance/constants';
+import React from 'react';
+import styled from 'styled-components';
+import { toast } from 'react-toastify';
+import { useAttendancesAttendanceIdRecordsGet } from '@pages/Attendance/hooks/useAttendancesAttendanceIdRecordsGet';
+
+//
+//
+//
+
+interface AttendanceStatusDropdownProps {
+ status: string;
+ attendanceId?: number;
+ memberId?: number;
+}
+
+//
+//
+//
+
+const AttendanceStatusDropdown: React.FC = ({
+ status,
+ attendanceId,
+ memberId,
+}) => {
+ //
+ const { mutateAttendancesAttendanceIdRecords } = useAttendancesAttendanceIdRecordsGet({
+ attendanceId: attendanceId ?? 0,
+ });
+
+ /**
+ *
+ */
+ const handleStatusChange = async (
+ e: React.ChangeEvent<{ value: unknown }>,
+ memberId?: number,
+ ) => {
+ const status = e.target.value as string;
+
+ try {
+ await api.patch(`/v2/api/attendances/${attendanceId}/records`, {
+ memberId,
+ attendanceResult: status.toUpperCase(),
+ });
+
+ mutateAttendancesAttendanceIdRecords();
+ toast.success('출석 상태가 성공적으로 변경되었습니다.');
+ } catch (error) {
+ toast.error('출석 상태 변경에 실패했습니다.');
+ }
+ };
+
+ //
+ //
+ //
+
+ return (
+ {
+ const icon = ATTENDANCE_ASSETS_MAP[value as keyof typeof ATTENDANCE_ASSETS_MAP]?.icon;
+ const text = ATTENDANCE_ASSETS_MAP[value as keyof typeof ATTENDANCE_ASSETS_MAP]?.text;
+
+ return (
+
+ {icon}
+ {text}
+
+ );
+ },
+ sx: {
+ '.MuiSelect-select': {
+ padding: '0.25rem',
+ height: '2.5rem',
+ },
+ },
+ },
+ }}
+ onChange={(e) => handleStatusChange(e, memberId)}
+ >
+ {Object.keys(ATTENDANCE_ASSETS_MAP).map((asset) => {
+ const icon = ATTENDANCE_ASSETS_MAP[asset as keyof typeof ATTENDANCE_ASSETS_MAP]?.icon;
+ const text = ATTENDANCE_ASSETS_MAP[asset as keyof typeof ATTENDANCE_ASSETS_MAP]?.text;
+
+ return (
+
+ );
+ })}
+
+ );
+};
+
+export default AttendanceStatusDropdown;
+
+//
+//
+//
+
+const StyledTextField = styled(TextField)`
+ width: 8.75rem;
+ background-color: ${({ theme }) => theme.colors.gray10};
+ border-radius: 0.5rem;
+`;
diff --git a/src/pages/Attendance/constants/index.tsx b/src/pages/Attendance/constants/index.tsx
new file mode 100644
index 00000000..791a5a0d
--- /dev/null
+++ b/src/pages/Attendance/constants/index.tsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import CotatoIcon from '@components/CotatoIcon';
+import { ReactComponent as OnlineIcon } from '@assets/attendance_online_icon.svg';
+import { ReactComponent as AbsentIcon } from '@assets/attendance_absent_icon.svg';
+import { CotatoAttendanceStatistic } from 'cotato-openapi-clients';
+
+export const STATUS_ASSETS = [
+ {
+ status: 'offline',
+ icon: theme.colors.sub3[40]} />,
+ text: '대면',
+ },
+
+ {
+ status: 'online',
+ icon: ,
+ text: '비대면',
+ },
+
+ {
+ status: 'late',
+ icon: theme.colors.secondary80} />,
+ text: '지각',
+ },
+
+ {
+ status: 'absent',
+ icon: ,
+ text: '결석',
+ },
+];
+
+export const ATTENDANCE_ASSETS_ICON_MAP: Record = {
+ offline: STATUS_ASSETS[0].icon,
+ online: STATUS_ASSETS[1].icon,
+ late: STATUS_ASSETS[2].icon,
+ absent: STATUS_ASSETS[3].icon,
+};
+
+export const ATTENDANCE_ASSETS_TEXT_MAP: Record = {
+ offline: STATUS_ASSETS[0].text,
+ online: STATUS_ASSETS[1].text,
+ late: STATUS_ASSETS[2].text,
+ absent: STATUS_ASSETS[3].text,
+};
+
+export const ATTENDANCE_ASSETS_MAP: Record<
+ keyof CotatoAttendanceStatistic,
+ { text: string; icon: JSX.Element }
+> = {
+ offline: {
+ text: '대면',
+ icon: theme.colors.sub3[40]} />,
+ },
+ online: {
+ text: '비대면',
+ icon: ,
+ },
+ late: {
+ text: '지각',
+ icon: theme.colors.secondary80} />,
+ },
+ absent: {
+ text: '결석',
+ icon: ,
+ },
+};
+
+export const STATISTICS_MAP = {
+ offline: '대면',
+ online: '비대면',
+ late: '지각',
+ absent: '결석',
+ undefined: '-',
+};
+
+export const ATTENDANCE_STATUS_TEXT = ['대면', '비대면', '지각', '결석'];
diff --git a/src/pages/Attendance/hooks/useAttendanceReportFilter.ts b/src/pages/Attendance/hooks/useAttendanceReportFilter.ts
new file mode 100644
index 00000000..ac1f4c04
--- /dev/null
+++ b/src/pages/Attendance/hooks/useAttendanceReportFilter.ts
@@ -0,0 +1,105 @@
+import { AttendResponseAttendanceTypeEnum } from '@/enums/attend';
+import { CotatoAttendResponseStatusEnum } from 'cotato-openapi-clients';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+
+//
+//
+//
+
+type QueryParams = {
+ [key: string]: string | string[] | null | undefined;
+};
+
+//
+//
+//
+
+export const useAttendanceReportFilter = () => {
+ const navigate = useNavigate();
+
+ //
+ const [searchParams] = useSearchParams();
+
+ /**
+ * update search params
+ */
+ const updateSearchParams = (value: string) => {
+ const newParams = new URLSearchParams(searchParams.toString());
+
+ if (!value) {
+ newParams.delete('search');
+ } else {
+ newParams.set('search', value);
+ }
+
+ applyQueryParams(newParams);
+ };
+
+ /***
+ * add query string to url
+ */
+ const applyQueryParams = (params: URLSearchParams) => {
+ navigate(`?${params.toString()}`);
+ };
+
+ /**
+ * add query param to url
+ * if already key exists, append value to key
+ */
+ const addQueryParams = (params: QueryParams) => {
+ const queryParams = new URLSearchParams(location.search);
+
+ Object.keys(params).forEach((key: string) => {
+ const value = params[key];
+ if (Array.isArray(value)) {
+ value.forEach((v) => queryParams.append(key, v));
+ } else if (value) {
+ queryParams.append(key, value);
+ }
+ });
+
+ applyQueryParams(queryParams);
+ };
+
+ /**
+ * delete query string from url
+ * if key has multiple values, delete only one value
+ */
+ const deleteQueryParams = (key: string, deleteValue: string) => {
+ const currentSearchParams = searchParams.getAll(key);
+
+ const newParams = new URLSearchParams(searchParams.toString());
+ const newValues = currentSearchParams.filter((value) => value !== deleteValue);
+
+ newParams.delete(key);
+
+ // if newValues is not empty, append new values
+ if (newValues.length) {
+ newValues.forEach((value) => newParams.append(key, value));
+ }
+
+ applyQueryParams(newParams);
+ };
+
+ /**
+ *
+ */
+ const toggleStatus = (
+ status: Omit | AttendResponseAttendanceTypeEnum,
+ ) => {
+ const currentStatus = searchParams.getAll('status');
+
+ if (currentStatus?.includes(status as string)) {
+ deleteQueryParams('status', status as string);
+ return;
+ }
+
+ addQueryParams({ status: status as string });
+ };
+
+ return {
+ currentStatus: searchParams.getAll('status'),
+ toggleStatus,
+ updateSearchParams,
+ };
+};
diff --git a/src/pages/Attendance/hooks/useAttendancesAttendanceIdRecordsGet.ts b/src/pages/Attendance/hooks/useAttendancesAttendanceIdRecordsGet.ts
new file mode 100644
index 00000000..4bf3599b
--- /dev/null
+++ b/src/pages/Attendance/hooks/useAttendancesAttendanceIdRecordsGet.ts
@@ -0,0 +1,57 @@
+import fetcher from '@utils/fetcher';
+import { CotatoAttendanceRecordResponse } from 'cotato-openapi-clients';
+import { useRef } from 'react';
+import useSWR from 'swr';
+
+//
+//
+//
+
+interface UseAttendancesAttendanceIdRecordsGetParams {
+ attendanceId?: number;
+}
+
+interface UseAttendancesAttendanceIdRecordsGetReturn {
+ attendancesAttendanceIdRecords: CotatoAttendanceRecordResponse[] | [];
+ isAttendancesAttendanceIdRecordsLoading: boolean;
+ isAttendancesAttendanceIdRecordsError: boolean;
+ mutateAttendancesAttendanceIdRecords: () => void;
+}
+
+//
+//
+//
+
+export const useAttendancesAttendanceIdRecordsGet = ({
+ attendanceId,
+}: UseAttendancesAttendanceIdRecordsGetParams): UseAttendancesAttendanceIdRecordsGetReturn => {
+ const _return = useRef(
+ {} as UseAttendancesAttendanceIdRecordsGetReturn,
+ );
+
+ //
+ const {
+ data: attendancesAttendanceIdRecords,
+ isLoading: isAttendancesAttendanceIdRecordsLoading,
+ error: isAttendancesAttendanceIdRecordsError,
+ mutate: mutateAttendancesAttendanceIdRecords,
+ } = useSWR(
+ `/v2/api/attendances/${attendanceId}/records`,
+ fetcher,
+ {
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ },
+ );
+
+ //
+ //
+ //
+
+ _return.current.attendancesAttendanceIdRecords = attendancesAttendanceIdRecords ?? [];
+ _return.current.isAttendancesAttendanceIdRecordsLoading = isAttendancesAttendanceIdRecordsLoading;
+ _return.current.isAttendancesAttendanceIdRecordsError = isAttendancesAttendanceIdRecordsError;
+ _return.current.mutateAttendancesAttendanceIdRecords = mutateAttendancesAttendanceIdRecords;
+
+ return _return.current;
+};
diff --git a/src/pages/Attendance/hooks/useAttendancesRecords.ts b/src/pages/Attendance/hooks/useAttendancesRecords.ts
new file mode 100644
index 00000000..f8d9b4f5
--- /dev/null
+++ b/src/pages/Attendance/hooks/useAttendancesRecords.ts
@@ -0,0 +1,63 @@
+import { useRef } from 'react';
+import { CotatoAttendanceRecordResponse } from 'cotato-openapi-clients';
+import useSWR from 'swr';
+import fetcher from '@utils/fetcher';
+
+//
+//
+//
+
+interface UseAttendancesRecordsParams {
+ generationId?: string;
+ name?: string;
+}
+
+interface UseAttendancesRecordsReturn {
+ attendancesRecords: CotatoAttendanceRecordResponse[] | [];
+ filteredAttendancesRecords: CotatoAttendanceRecordResponse[] | [];
+ isAttendancesRecordsLoading: boolean;
+ isAttendancesRecordsError: boolean;
+ mutateAttendancesRecords: () => void;
+}
+
+//
+//
+//
+
+export const useAttendancesRecords = ({
+ generationId,
+ name,
+}: UseAttendancesRecordsParams): UseAttendancesRecordsReturn => {
+ const _return = useRef({} as UseAttendancesRecordsReturn);
+
+ //
+ const {
+ data: attendancesRecords,
+ isLoading: isAttendancesRecordsLoading,
+ error: isAttendancesRecordsError,
+ mutate: mutateAttendancesRecords,
+ } = useSWR(
+ `/v2/api/attendances/records?generationId=${generationId}`,
+ fetcher,
+ {
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ },
+ );
+
+ const filteredAttendancesRecords = attendancesRecords?.filter(
+ (record) => record.memberInfo?.name?.toLowerCase().includes(name?.toLowerCase() ?? '') ?? false,
+ );
+
+ //
+ //
+ //
+
+ _return.current.attendancesRecords = attendancesRecords ?? [];
+ _return.current.filteredAttendancesRecords = filteredAttendancesRecords ?? [];
+ _return.current.isAttendancesRecordsLoading = isAttendancesRecordsLoading;
+ _return.current.isAttendancesRecordsError = isAttendancesRecordsError;
+ _return.current.mutateAttendancesRecords = mutateAttendancesRecords;
+
+ return _return.current;
+};
diff --git a/src/pages/Attendance/hooks/useAttendancesRecordsMembers.tsx b/src/pages/Attendance/hooks/useAttendancesRecordsMembers.tsx
new file mode 100644
index 00000000..49365bba
--- /dev/null
+++ b/src/pages/Attendance/hooks/useAttendancesRecordsMembers.tsx
@@ -0,0 +1,60 @@
+import { useRef } from 'react';
+import fetcherWithParams from '@utils/fetcherWithParams';
+import {
+ CotatoAttendanceRecordResponse,
+ CotatoMemberAttendanceRecordsResponse,
+} from 'cotato-openapi-clients';
+import useSWR from 'swr';
+
+//
+//
+//
+
+interface UseAttendancesRecordsMembersParams {
+ generationId?: string;
+}
+
+interface UseAttendancesRecordsMembersReturn {
+ attendancesRecordsMembers: CotatoMemberAttendanceRecordsResponse['memberAttendResponses'] | [];
+ isAttendancesRecordsMembersLoading: boolean;
+ isAttendancesRecordsMembersError: boolean;
+ mutateAttendancesRecordsMembers: () => void;
+}
+
+//
+//
+//
+
+export const useAttendancesRecordsMembers = ({
+ generationId,
+}: UseAttendancesRecordsMembersParams): UseAttendancesRecordsMembersReturn => {
+ const _return = useRef(
+ {} as UseAttendancesRecordsMembersReturn,
+ );
+
+ //
+ const {
+ data,
+ isLoading: isAttendancesRecordsMembersLoading,
+ error: isAttendancesRecordsMembersError,
+ mutate: mutateAttendancesRecordsMembers,
+ } = useSWR(
+ '/v2/api/attendances/records/members',
+ (url: string) => fetcherWithParams(url, { generationId }),
+ {
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ },
+ );
+
+ //
+ //
+ //
+
+ _return.current.attendancesRecordsMembers = data?.memberAttendResponses ?? [];
+ _return.current.isAttendancesRecordsMembersLoading = isAttendancesRecordsMembersLoading;
+ _return.current.isAttendancesRecordsMembersError = isAttendancesRecordsMembersError;
+ _return.current.mutateAttendancesRecordsMembers = mutateAttendancesRecordsMembers;
+
+ return _return.current;
+};
diff --git a/src/pages/Attendance/utils/util.ts b/src/pages/Attendance/utils/util.ts
new file mode 100644
index 00000000..6202a741
--- /dev/null
+++ b/src/pages/Attendance/utils/util.ts
@@ -0,0 +1,17 @@
+import { CotatoAttendanceRecordResponse, CotatoAttendanceStatistic } from 'cotato-openapi-clients';
+
+/**
+ * Get current statistic
+ */
+export const getCurrentStatistic = (record: CotatoAttendanceRecordResponse | undefined) => {
+ // online, offline, late, absent
+
+ const keys = Object.keys(record?.statistic || {});
+
+ // 0이 아닌 값이 있는 인덱스 == online, offline, late, absent 중 하나
+ const currentInfoIndex = Object.values(record?.statistic || {}).findIndex((value) => value !== 0);
+
+ const currentStatistic = keys[currentInfoIndex] as keyof CotatoAttendanceStatistic;
+
+ return currentInfoIndex === -1 ? 'undefined' : currentStatistic;
+};
diff --git a/src/pages/CS/CSHome.tsx b/src/pages/CS/CSHome.tsx
index 65ef7ae7..35bfe2ba 100644
--- a/src/pages/CS/CSHome.tsx
+++ b/src/pages/CS/CSHome.tsx
@@ -1,19 +1,20 @@
-import React, { useCallback, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { styled } from 'styled-components';
import CSContent from '@pages/CS/CSContent';
+import CotatoDropBox from '@components/CotatoDropBox';
import CSModal from '@pages/CS/CSModal';
import { IEducation } from '@/typing/db';
import api from '@/api/api';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate, useParams } from 'react-router-dom';
import { CotatoGenerationInfoResponse } from 'cotato-openapi-clients';
import { useGeneration } from '@/hooks/useGeneration';
import useUser from '@/hooks/useUser';
-import GenerationDropBox from '@components/GenerationDropBox';
import CotatoIcon from '@components/CotatoIcon';
import { IconButton } from '@mui/material';
const CSHome = () => {
- const { currentGeneration } = useGeneration();
+ const { generationId } = useParams();
+ const { generations } = useGeneration();
const { user } = useUser();
const [educations, setEducations] = useState();
@@ -21,7 +22,7 @@ const CSHome = () => {
const [modifyEducation, setModifyEducation] = useState();
const [selectedGeneration, setSelectedGeneration] = useState<
undefined | CotatoGenerationInfoResponse
- >(currentGeneration);
+ >();
const navigate = useNavigate();
@@ -66,13 +67,25 @@ const CSHome = () => {
setIsCSModalOpen(false);
}, []);
+ useEffect(() => {
+ if (!generationId || !generations) {
+ return;
+ }
+
+ const generation = generations?.find(
+ (generation) => generation.generationId === Number(generationId),
+ );
+ setSelectedGeneration(generation);
+ fetchEducations(Number(generationId));
+ }, [generations, generationId]);
+
return (
<>
CS 문제풀이
-
+ {generations && }
{(user?.role === 'ADMIN' || user?.role === 'EDUCATION') && (
diff --git a/src/pages/MyPage/CSRecord.tsx b/src/pages/MyPage/CSRecord.tsx
index 24db9049..b44f5529 100644
--- a/src/pages/MyPage/CSRecord.tsx
+++ b/src/pages/MyPage/CSRecord.tsx
@@ -5,7 +5,7 @@ import useSWR from 'swr';
import localeKr from '@/assets/locale/locale_kr.json';
import useUser from '@/hooks/useUser';
import { useGeneration } from '@/hooks/useGeneration';
-import GenerationDropBox from '@components/GenerationDropBox';
+import CotatoDropBox from '@components/CotatoDropBox';
import { useSearchParams } from 'react-router-dom';
//
@@ -27,6 +27,7 @@ const medalImgSrcs = [
const CSRecord = () => {
const { user } = useUser();
const [params] = useSearchParams();
+ const { generations } = useGeneration();
const generationId = params.get('generationId');
const [selectedGenerationId, setSelectedGenerationId] = React.useState(
@@ -48,11 +49,14 @@ const CSRecord = () => {
내가 풀어본 CS 문제풀이
- {
- setSelectedGenerationId(generation?.generationId?.toString());
- }}
- />
+ {generations && (
+ {
+ setSelectedGenerationId(generation?.generationId?.toString());
+ }}
+ />
+ )}
{/* */}
diff --git a/src/pages/Session/SessionHome.tsx b/src/pages/Session/SessionHome.tsx
index 0a1b0cf5..65624b6d 100644
--- a/src/pages/Session/SessionHome.tsx
+++ b/src/pages/Session/SessionHome.tsx
@@ -11,9 +11,8 @@ import {
CotatoLocalTime,
CotatoSessionListResponse,
} from 'cotato-openapi-clients';
-import GenerationDropBox from '@components/GenerationDropBox';
+import CotatoDropBox from '@components/CotatoDropBox';
import { useMediaQuery } from '@mui/material';
-import { DropBoxColorEnum } from '@/enums/DropBoxColor';
import { device } from '@theme/media';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Pagination, Scrollbar } from 'swiper/modules';
@@ -24,13 +23,15 @@ import 'swiper/css';
import 'swiper/css/pagination';
import 'swiper/css/scrollbar';
import { toast } from 'react-toastify';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { useGeneration } from '@/hooks/useGeneration';
//
//
//
const SessionHome = () => {
+ const { currentGeneration, generations } = useGeneration();
const [selectedGeneration, setSelectedGeneration] = useState();
const { data: sessionList, mutate: mutateSessionList } = useSWR(
@@ -47,6 +48,7 @@ const SessionHome = () => {
const [selectedSession, setSelectedSession] = useState(null);
const isTabletOrSmaller = useMediaQuery(`(max-width:${device.tablet})`);
+ const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
/**
@@ -84,6 +86,7 @@ const SessionHome = () => {
*/
const handleGenerationChange = (generation: CotatoGenerationInfoResponse) => {
setSelectedGeneration(generation);
+ setSearchParams({ generationId: generation.generationId!.toString() });
};
/**
@@ -204,7 +207,6 @@ const SessionHome = () => {
*
*/
const handleSessionUpdate = (session: SessionUploadInfo) => {
- console.log(session);
if (!session.sessionId) {
return;
}
@@ -304,12 +306,16 @@ const SessionHome = () => {
const renderSettingTab = () => {
return (
-
+ {generations && (
+
+ )}
{userData?.role === 'ADMIN' && !isTabletOrSmaller && (
setIsAddModalOpen(true)} />
)}
@@ -378,6 +384,26 @@ const SessionHome = () => {
);
};
+ /**
+ * set generationId from url
+ */
+ useEffect(() => {
+ if (!currentGeneration || !generations) {
+ return;
+ }
+
+ const generationId = searchParams.get('generationId');
+
+ if (generationId) {
+ setSelectedGeneration(
+ generations?.find((generation) => generation.generationId === Number(generationId)),
+ );
+ } else {
+ setSearchParams({ generationId: currentGeneration!.generationId!.toString() });
+ setSelectedGeneration(currentGeneration);
+ }
+ }, [currentGeneration, generations]);
+
/**
*
*/