diff --git a/backend/src/main/java/sw_css/file/api/FileController.java b/backend/src/main/java/sw_css/file/api/FileController.java index 170431f8..e0d43a60 100644 --- a/backend/src/main/java/sw_css/file/api/FileController.java +++ b/backend/src/main/java/sw_css/file/api/FileController.java @@ -15,8 +15,8 @@ public class FileController { private final FileService fileService; - @GetMapping - public ResponseEntity downloadFile(@PathVariable final String fileName) throws IOException { + @GetMapping("/{fileName}") + public ResponseEntity downloadFile(@PathVariable("fileName") final String fileName) throws IOException { byte[] downloadFile = fileService.downloadFileFromFileSystem(fileName); return ResponseEntity.ok(downloadFile); } diff --git a/backend/src/main/resources/static/files/history_register_sample.xlsx b/backend/src/main/resources/static/files/history_register_sample.xlsx new file mode 100644 index 00000000..8a6199b0 Binary files /dev/null and b/backend/src/main/resources/static/files/history_register_sample.xlsx differ diff --git a/backend/src/main/resources/static/files/history_standard.pdf b/backend/src/main/resources/static/files/history_standard.pdf new file mode 100644 index 00000000..ab37a178 Binary files /dev/null and b/backend/src/main/resources/static/files/history_standard.pdf differ diff --git a/backend/src/test/java/sw_css/restdocs/docs/FileApiDocsTest.java b/backend/src/test/java/sw_css/restdocs/docs/FileApiDocsTest.java index 4e7c2a7d..8808a7db 100644 --- a/backend/src/test/java/sw_css/restdocs/docs/FileApiDocsTest.java +++ b/backend/src/test/java/sw_css/restdocs/docs/FileApiDocsTest.java @@ -1,14 +1,17 @@ package sw_css.restdocs.docs; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.request.PathParametersSnippet; import sw_css.file.api.FileController; import sw_css.restdocs.RestDocsTest; @@ -20,13 +23,17 @@ public class FileApiDocsTest extends RestDocsTest { public void downloadFile() throws Exception { // given final byte[] response = new byte[]{}; + final PathParametersSnippet pathParameters = pathParameters( + parameterWithName("fileName").description("조회하고자 하는 파일의 이름") + ); // when - when(fileService.downloadFileFromFileSystem(anyString())).thenReturn(response); + when(fileService.downloadFileFromFileSystem(any())).thenReturn(response); // then - mockMvc.perform(get("/files/dd71ceeb-a721-462f-9ea1-411415f57607_Eg7BBFlUcAAQMzI.jpeg")) + final String fileName = "test-file.jpeg"; + mockMvc.perform(RestDocumentationRequestBuilders.get("/files/{fileName}", fileName)) .andExpect(status().isOk()) - .andDo(document("download-file")); + .andDo(document("download-file", pathParameters)); } } diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index e17555d9..f3b119b0 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -1,6 +1,6 @@ { "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint"], + "plugins": ["@typescript-eslint", "react", "tailwindcss"], "extends": [ "next/core-web-vitals", "plugin:@typescript-eslint/recommended", diff --git a/frontend/public/images/admin/pdf_icon.svg b/frontend/public/images/admin/pdf_icon.svg new file mode 100644 index 00000000..810cb55b --- /dev/null +++ b/frontend/public/images/admin/pdf_icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/app/(withSidebar)/my-page/components/MilestoneHistorySection/index.tsx b/frontend/src/app/(withSidebar)/my-page/components/MilestoneHistorySection/index.tsx index 81a15ddd..f918a9df 100644 --- a/frontend/src/app/(withSidebar)/my-page/components/MilestoneHistorySection/index.tsx +++ b/frontend/src/app/(withSidebar)/my-page/components/MilestoneHistorySection/index.tsx @@ -26,7 +26,7 @@ const MilestoneHistorySection = async () => { ); return ( -
+
{milestoneHistoriesOfStudent?.content.map((milestoneHistory) => ( diff --git a/frontend/src/app/(withSidebar)/my-page/components/MilestoneSection/index.tsx b/frontend/src/app/(withSidebar)/my-page/components/MilestoneSection/index.tsx index 524ebbe3..13f545fa 100644 --- a/frontend/src/app/(withSidebar)/my-page/components/MilestoneSection/index.tsx +++ b/frontend/src/app/(withSidebar)/my-page/components/MilestoneSection/index.tsx @@ -48,17 +48,17 @@ const MilestoneSection = () => { ); return ( -
+
-
+
{searchFilterPeriod.startDate}~ {searchFilterPeriod.endDate}
-
+
{milestoneInfoTypes.map((type) => (
{selectedInfoType === MilestoneInfoType.TOTAL && ( -
+
@@ -85,7 +85,7 @@ const MilestoneSection = () => { /> )} {selectedInfoType === MilestoneInfoType.HISTORY && ( - + )}
diff --git a/frontend/src/app/(withSidebar)/my-page/components/StudentInfoSection/StudentInfoLabel/index.tsx b/frontend/src/app/(withSidebar)/my-page/components/StudentInfoSection/StudentInfoLabel/index.tsx index 3bdb0ad3..ac0b261e 100644 --- a/frontend/src/app/(withSidebar)/my-page/components/StudentInfoSection/StudentInfoLabel/index.tsx +++ b/frontend/src/app/(withSidebar)/my-page/components/StudentInfoSection/StudentInfoLabel/index.tsx @@ -6,7 +6,7 @@ interface StudentInfoLabelProps { } const StudentInfoLabel = ({ label, value }: StudentInfoLabelProps) => ( -

+

{label} {value}

diff --git a/frontend/src/app/(withSidebar)/my-page/components/StudentInfoSection/index.tsx b/frontend/src/app/(withSidebar)/my-page/components/StudentInfoSection/index.tsx index 89989c4e..db80e06d 100644 --- a/frontend/src/app/(withSidebar)/my-page/components/StudentInfoSection/index.tsx +++ b/frontend/src/app/(withSidebar)/my-page/components/StudentInfoSection/index.tsx @@ -18,16 +18,16 @@ const StudentInfoSection = () => { {member && (
-
+

{member.name}

{member.email}

-
-
- - {member.minor && } - {member.doubleMajor && } -
+
+ + {member.minor && } + {member.doubleMajor && } +
+
{ const auth = useAppSelector((state) => state.auth).value; @@ -18,7 +18,7 @@ const MilestoneDetail = ({ startDate, endDate }: Period) => { return (
-
+
{milestoneGroups.map((group) => ( - + 활동명 - 역량 구분 - 획득 점수 - 활동일 + 구분 + 점수 + 활동일 - + {milestoneHistoriesOfStudent?.content.map((milestoneHistory) => ( - - {milestoneHistory.description} - + + + {milestoneHistory.description} + + - {milestoneHistory.milestone.score * milestoneHistory.count} - {milestoneHistory.activatedAt.slice(0, 10)} + {milestoneHistory.milestone.score * milestoneHistory.count} + + {milestoneHistory.activatedAt.slice(0, 10).replaceAll('-', '.')} + ))} diff --git a/frontend/src/app/(withSidebar)/my-page/milestone/components/MilestoneOverview/index.tsx b/frontend/src/app/(withSidebar)/my-page/milestone/components/MilestoneOverview/index.tsx index 247f78a0..42c38641 100644 --- a/frontend/src/app/(withSidebar)/my-page/milestone/components/MilestoneOverview/index.tsx +++ b/frontend/src/app/(withSidebar)/my-page/milestone/components/MilestoneOverview/index.tsx @@ -4,13 +4,13 @@ import { useMemo } from 'react'; import MilestoneChart from '@/components/MilestoneChart'; import MilestoneTable from '@/components/MilestoneTable'; import { initialMilestoneOverview } from '@/data/milestone'; +import { useAppSelector } from '@/lib/hooks/redux'; import { useMilestoneScoresOfStudentQuery } from '@/lib/hooks/useApi'; import { Period } from '@/types/common'; import { MilestoneOverviewScore } from '@/types/milestone'; import { MilestoneWrapper } from './styled'; import MilestoneDetail from '../MilestoneDetail'; -import { useAppSelector } from '@/lib/hooks/redux'; interface MilestoneOverviewProps { searchFilterPeriod: Period; @@ -37,7 +37,7 @@ const MilestoneOverview = ({ searchFilterPeriod }: MilestoneOverviewProps) => { [milestoneScoresOfStudent], ); return ( -
+
diff --git a/frontend/src/app/(withSidebar)/my-page/milestone/page.tsx b/frontend/src/app/(withSidebar)/my-page/milestone/page.tsx index fe5389cc..d4b7d1cb 100644 --- a/frontend/src/app/(withSidebar)/my-page/milestone/page.tsx +++ b/frontend/src/app/(withSidebar)/my-page/milestone/page.tsx @@ -8,7 +8,7 @@ import { Period } from '@/types/common'; import MilestoneHistoryTable from './components/MilestoneHistoryTable'; import MilestoneOverview from './components/MilestoneOverview'; -import { Content, SubTitle, Title } from './styled'; +import { Content, SubTitle } from './styled'; import MilestonePeriodSearchForm from '../../../../components/MilestonePeriodSearchForm'; const Page = () => { @@ -20,8 +20,8 @@ const Page = () => { return ( -
- 마일스톤 획득 내역 +
+

마일스톤 획득 내역

{
획득 내역 {/* TODO 제대로 페이지네이션 처리 하기 */} - + ); }; diff --git a/frontend/src/app/(withSidebar)/my-page/milestone/register/components/MilestoneHistoryDeleteButton/index.tsx b/frontend/src/app/(withSidebar)/my-page/milestone/register/components/MilestoneHistoryDeleteButton/index.tsx index b6ebc1ec..e536e30a 100644 --- a/frontend/src/app/(withSidebar)/my-page/milestone/register/components/MilestoneHistoryDeleteButton/index.tsx +++ b/frontend/src/app/(withSidebar)/my-page/milestone/register/components/MilestoneHistoryDeleteButton/index.tsx @@ -2,6 +2,7 @@ import { useRouter } from 'next/navigation'; +import { toast } from 'react-toastify'; import { useMilestoneHistoryDeleteMutation } from '@/lib/hooks/useApi'; interface MilestoneHistoryDeleteButtonProps { @@ -19,7 +20,7 @@ const MilestoneHistoryDeleteButton = ({ historyId }: MilestoneHistoryDeleteButto if (router) router.refresh(); }, onError: () => { - window.alert('삭제에 실패하였습니다.'); + toast.error('삭제에 실패하였습니다.'); }, }); } diff --git a/frontend/src/app/(withSidebar)/my-page/milestone/register/components/MilestoneHistoryTable/index.tsx b/frontend/src/app/(withSidebar)/my-page/milestone/register/components/MilestoneHistoryTable/index.tsx index 466c6c5d..5b9e591a 100644 --- a/frontend/src/app/(withSidebar)/my-page/milestone/register/components/MilestoneHistoryTable/index.tsx +++ b/frontend/src/app/(withSidebar)/my-page/milestone/register/components/MilestoneHistoryTable/index.tsx @@ -1,6 +1,5 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ /* eslint-disable max-len */ -/* eslint-disable no-alert */ import MilestoneHistoryStatusLabel from '@/app/(withSidebar)/my-page/components/MilestoneHistoryStatusLabel'; import { getMilestoneHistoriesOfStudent } from '@/lib/api/server.api'; @@ -25,29 +24,44 @@ const MilestoneHistoryTable = async () => { No - 제목 + 제목 점수 - 활동일 - 등록일 - 진행 상황 - 처리 + 활동일 + 등록일 + 진행 상황 + 처리 + 실적 내역 {milestoneHistories?.content.map((milestoneHistory, index) => ( {index + 1} - {milestoneHistory.description} + {milestoneHistory.description} {milestoneHistory.milestone.score * milestoneHistory.count} - {milestoneHistory.activatedAt} - {milestoneHistory.createdAt.slice(0, 10)} - + {milestoneHistory.activatedAt.replaceAll('-', '.')} + {milestoneHistory.createdAt.slice(0, 10).replaceAll('-', '.')} + - + + + + +
+ +
{milestoneHistory.description}
+
+
+
활동: {milestoneHistory.activatedAt.replaceAll('-', '.')}
+
등록: {milestoneHistory.createdAt.slice(0, 10).replaceAll('-', '.')}
+
diff --git a/frontend/src/app/(withSidebar)/my-page/milestone/register/write/components/MilestoneDropdown/index.tsx b/frontend/src/app/(withSidebar)/my-page/milestone/register/write/components/MilestoneDropdown/index.tsx index 7f5d6b1a..61d56f4f 100644 --- a/frontend/src/app/(withSidebar)/my-page/milestone/register/write/components/MilestoneDropdown/index.tsx +++ b/frontend/src/app/(withSidebar)/my-page/milestone/register/write/components/MilestoneDropdown/index.tsx @@ -68,8 +68,8 @@ const MilestoneDropdown = ({ ...props }: MilestoneDropdownProps) => { }, [milestoneId, milestoneOptions, setSelectedMilestone]); return ( -
-
+
+
{ errorText={dropdownProps.errorText ? '' : undefined} />
-
+
{ const handleSubmitButtonClick = (values: MilestoneHistoryCreateDto) => { createMilestoneHistory(values, { onSuccess: () => { - window.alert('실적 등록에 성공하였습니다.'); + toast.info('실적 등록에 성공하였습니다.'); router.push('/my-page/milestone/register'); }, onError: () => { - window.alert('실적 등록에 실패하였습니다.'); + toast.error('실적 등록에 실패하였습니다.'); }, }); }; @@ -91,36 +92,44 @@ const Page = () => { setSelectedMilestone={setSelectedMilestone} errorText={touched.milestoneId && errors.milestoneId ? errors.milestoneId : undefined} /> -
- - x - - = - +
+
+ +
+
+ x + +
+
+ = +
+ +
+
-
+
approveMilestoneHistory(historyId, { onSuccess: () => { - window.alert('실적 내역을 승인하였습니다.'); + toast.info('실적 내역을 승인하였습니다.'); router.refresh(); }, onError: () => { - window.alert('실적 내역을 승인하는 데 실패했습니다.'); + toast.error('실적 내역을 승인하는 데 실패했습니다.'); }, }); const handleRejectButtonClick = () => @@ -40,11 +41,11 @@ const MilestoneHistoryStatusChangeButton = ({ historyId, status }: MilestoneHist { historyId, rejectReason }, { onSuccess: () => { - window.alert('실적 내역을 반려하였습니다.'); + toast.info('실적 내역을 반려하였습니다.'); router.refresh(); }, onError: () => { - window.alert('실적 내역을 반려하는 데 실패했습니다.'); + toast.error('실적 내역을 반려하는 데 실패했습니다.'); }, }, ); @@ -52,11 +53,11 @@ const MilestoneHistoryStatusChangeButton = ({ historyId, status }: MilestoneHist const handleCancelButtonClick = () => cancelMilestoneHistory(historyId, { onSuccess: () => { - window.alert(`${convertMilestoneHistoryStatus(status)}을(를) 취소했습니다.`); + toast.info(`${convertMilestoneHistoryStatus(status)}을(를) 취소했습니다.`); router.refresh(); }, onError: () => { - window.alert(`${convertMilestoneHistoryStatus(status)} 취소에 실패하였습니다.`); + toast.error(`${convertMilestoneHistoryStatus(status)} 취소에 실패하였습니다.`); }, }); switch (status) { @@ -82,7 +83,7 @@ const MilestoneHistoryStatusChangeButton = ({ historyId, status }: MilestoneHist diff --git a/frontend/src/app/admin/milestone/register/page.tsx b/frontend/src/app/admin/milestone/register/page.tsx new file mode 100644 index 00000000..113977a4 --- /dev/null +++ b/frontend/src/app/admin/milestone/register/page.tsx @@ -0,0 +1,117 @@ +/* eslint-disable max-len */ +/* eslint-disable operator-linebreak */ +/* eslint-disable implicit-arrow-linebreak */ + +'use client'; + +import { Form, Formik } from 'formik'; +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; +import { useMemo } from 'react'; +import * as Yup from 'yup'; +import { toast } from 'react-toastify'; + +import { FileUploader } from '@/app/components/Formik/FileUploader'; +import { useRegisterHistoryInBatchMutation } from '@/lib/hooks/useAdminApi'; +import { useFileQuery } from '@/lib/hooks/useApi'; + +const validationSchema = Yup.object().shape({ + file: Yup.mixed() + .required('파일을 첨부해주세요.') + .test( + 'fileFormat', + 'Excel 파일(.xlsx)만 업로드 가능합니다.', + (value) => + value instanceof File && + ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel'].includes( + value.type, + ), + ), +}); + +interface HistoryRegisterFormProps { + file?: File; +} + +const initialValues: HistoryRegisterFormProps = { + file: undefined, +}; + +const Page = () => { + const router = useRouter(); + const { data: standardFile } = useFileQuery('history_standard.pdf'); + const standardFileUrl = useMemo(() => { + if (standardFile) { + return URL.createObjectURL(standardFile); + } + return ''; + }, [standardFile]); + + const { data: sampleFile } = useFileQuery('history_register_sample.xlsx'); + const sampleFileUrl = useMemo(() => { + if (sampleFile) { + return URL.createObjectURL(sampleFile); + } + return ''; + }, [sampleFile]); + + const { mutate: registerHistories } = useRegisterHistoryInBatchMutation(); + + return ( + <> +
+

+ 점수 산정 기준 표 - pdf + + 점수산정파일.pdf + +

+

+ 일괄등록 파일 예시 - xlsx + + sample.xlsx + +

+
+ { + registerHistories(values.file, { + onSuccess: () => { + toast.info('실적 등록에 성공하였습니다.'); + router.refresh(); + }, + onError: () => { + toast.error('실적 등록에 실패하였습니다.'); + }, + }); + setSubmitting(false); + }} + > + {({ isSubmitting, touched, handleBlur, setFieldValue, errors }) => ( +
+ e.currentTarget.files && setFieldValue('file', e.currentTarget.files[0])} + onBlur={handleBlur} + errorText={touched.file && errors.file ? errors.file : undefined} + /> + + + )} +
+ + ); +}; + +export default Page; diff --git a/frontend/src/app/components/Formik/TextInput/index.tsx b/frontend/src/app/components/Formik/TextInput/index.tsx index 432f9e44..fe03ce5c 100644 --- a/frontend/src/app/components/Formik/TextInput/index.tsx +++ b/frontend/src/app/components/Formik/TextInput/index.tsx @@ -21,7 +21,6 @@ export const TextInput = ({ isRequired = false, ...props }: TextInputProps) => { {label} {isRequired && *} { if (e.key === 'Enter') { onKeyDownEnter?.(); @@ -33,6 +32,7 @@ export const TextInput = ({ isRequired = false, ...props }: TextInputProps) => { onChangeText?.(e.target.value); }} className={`m-0 rounded-sm border-[1px] border-border p-3 text-base ${hasError && 'border-red-400'}`} + {...inputProps} /> {errorText && {errorText}}
diff --git a/frontend/src/app/components/SignIn/components/InputUserInfo/index.tsx b/frontend/src/app/components/SignIn/components/InputUserInfo/index.tsx index 9abf506f..16c8292b 100644 --- a/frontend/src/app/components/SignIn/components/InputUserInfo/index.tsx +++ b/frontend/src/app/components/SignIn/components/InputUserInfo/index.tsx @@ -21,7 +21,7 @@ const InputUserInfo = () => { signIn({ token: 'token', username: 'name', - uid: '1', + uid: 202055558, isModerator: true, }), ); diff --git a/frontend/src/components/MilestonePeriodSearchForm/index.tsx b/frontend/src/components/MilestonePeriodSearchForm/index.tsx index 726f6eb1..581b6bcc 100644 --- a/frontend/src/components/MilestonePeriodSearchForm/index.tsx +++ b/frontend/src/components/MilestonePeriodSearchForm/index.tsx @@ -20,7 +20,7 @@ const MilestonePeriodSearchForm = ({ }; return ( -
+
response, - (error) => { - Promise.reject(categorizeError(error)); - }, + (error) => Promise.reject(categorizeError(error)), ); diff --git a/frontend/src/lib/hooks/useAdminApi.ts b/frontend/src/lib/hooks/useAdminApi.ts index f984862c..00cec1e2 100644 --- a/frontend/src/lib/hooks/useAdminApi.ts +++ b/frontend/src/lib/hooks/useAdminApi.ts @@ -48,3 +48,14 @@ export const useMilestoneHistoryStatusCancelMutation = () => await client.patch(`/admin/milestones/histories/${historyId}/cancel`); }, }); + +export const useRegisterHistoryInBatchMutation = () => + useAxiosMutation({ + mutationFn: async (file?: File) => { + const formdata = new FormData(); + formdata.append('file', file!); + await client.post('/admin/milestones/histories', formdata, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }, + }); diff --git a/frontend/src/lib/hooks/useAxios.ts b/frontend/src/lib/hooks/useAxios.ts index 0cd874b3..e84a066d 100644 --- a/frontend/src/lib/hooks/useAxios.ts +++ b/frontend/src/lib/hooks/useAxios.ts @@ -23,10 +23,7 @@ const handleError = (error: Error, pathname: string) => { if (error instanceof BusinessError && error.originalError instanceof AxiosError && error.originalError.response) { toast.error(error.originalError.response.data.detail); - return; } - - throw error; }; export const useAxiosQuery = (options: UseQueryOptions) => { diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index e9909c75..23bf143e 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -73,8 +73,10 @@ module.exports = { dark: '#3F3F3F', }, semantic: { - 'errpr-light': '#EC3B4C', - 'error-main': '#B30818', + error: { + light: '#EC3B4C', + main: '#B30818', + }, }, }, },