diff --git a/apps/graduate/src/admin/entities/graduation-approval/model/useGraduationApproval.ts b/apps/graduate/src/admin/entities/graduation-approval/model/useGraduationApproval.ts index 566df76c..e3de98aa 100644 --- a/apps/graduate/src/admin/entities/graduation-approval/model/useGraduationApproval.ts +++ b/apps/graduate/src/admin/entities/graduation-approval/model/useGraduationApproval.ts @@ -10,7 +10,6 @@ import { APPROVE_OK_TEXT, APPROVE_CANCEL_TEXT, APPROVE_EMPTY, - APPROVE_FAILED, APPROVE_NOTHING, APPROVE_REASON_ALREADY_APPROVED, APPROVE_REASON_FAILED, @@ -166,8 +165,8 @@ export function useGraduationApproval({ } else { toast.warning(APPROVE_NOTHING); } - } catch (error) { - toast.error(error instanceof Error ? error.message : APPROVE_FAILED); + } catch { + /* 실패 시 MutationCache 전역 토스트로 안내 */ } }, }); diff --git a/apps/graduate/src/admin/pages/all/model/useAllManagement.ts b/apps/graduate/src/admin/pages/all/model/useAllManagement.ts index cf9823af..39861e33 100644 --- a/apps/graduate/src/admin/pages/all/model/useAllManagement.ts +++ b/apps/graduate/src/admin/pages/all/model/useAllManagement.ts @@ -50,11 +50,7 @@ export const useAllManagement = () => { } }, [searchParams, navigate]); - const { data: schedules, error: scheduleError } = useScheduleList(); - - if (scheduleError) { - toast.error('스케줄 정보를 불러오는데 실패했습니다.'); - } + const { data: schedules } = useScheduleList(); const { data, isLoading } = useFetchGraduationUsers({ page: page - 1, @@ -104,10 +100,8 @@ export const useAllManagement = () => { try { await removeGraduationUsers(selectedIds); toast.success('선택한 학생을 삭제했습니다.'); - } catch (error) { - toast.error( - error instanceof Error ? error.message : '삭제에 실패했습니다.', - ); + } catch { + /* 실패 시 MutationCache 전역 토스트 */ } }, }); diff --git a/apps/graduate/src/admin/pages/filePreview/model/useFile.ts b/apps/graduate/src/admin/pages/filePreview/model/useFile.ts index 003aa7d3..927af07b 100644 --- a/apps/graduate/src/admin/pages/filePreview/model/useFile.ts +++ b/apps/graduate/src/admin/pages/filePreview/model/useFile.ts @@ -27,5 +27,6 @@ export function useFile( } }, enabled: enabled, + meta: { suppressErrorToast: true }, }); } diff --git a/apps/graduate/src/admin/pages/filePreview/ui/FilePreviewPage.tsx b/apps/graduate/src/admin/pages/filePreview/ui/FilePreviewPage.tsx index 614316c5..1cf28e7a 100644 --- a/apps/graduate/src/admin/pages/filePreview/ui/FilePreviewPage.tsx +++ b/apps/graduate/src/admin/pages/filePreview/ui/FilePreviewPage.tsx @@ -108,8 +108,8 @@ export default function FilePreviewPage() { const handleApprove = async () => { try { await approveGraduationUsers([graduationUserId]); - } catch (error) { - toast.error('승인에 실패했습니다.'); + } catch { + /* 실패 시 MutationCache 전역 토스트 */ } }; diff --git a/apps/graduate/src/admin/pages/schedule/ui/ScheduleDescription.tsx b/apps/graduate/src/admin/pages/schedule/ui/ScheduleDescription.tsx index ccb99d9b..b77d4d54 100644 --- a/apps/graduate/src/admin/pages/schedule/ui/ScheduleDescription.tsx +++ b/apps/graduate/src/admin/pages/schedule/ui/ScheduleDescription.tsx @@ -39,9 +39,6 @@ export default function ScheduleDescription() { setIsSaved(true); toast.success('설명이 저장되었습니다!'); }, - onError: () => { - toast.error('설명 저장에 실패했습니다.'); - }, }, ); }; diff --git a/apps/graduate/src/admin/pages/schedule/ui/ScheduleEditModal.tsx b/apps/graduate/src/admin/pages/schedule/ui/ScheduleEditModal.tsx index c544af8a..a90bc38b 100644 --- a/apps/graduate/src/admin/pages/schedule/ui/ScheduleEditModal.tsx +++ b/apps/graduate/src/admin/pages/schedule/ui/ScheduleEditModal.tsx @@ -74,9 +74,6 @@ export default function ScheduleEditModal({ setIsModalOpen(false); reset(); }, - onError: () => { - toast.error('일정 수정에 실패했습니다.'); - }, }, ); }; diff --git a/apps/graduate/src/admin/widgets/StudentAddModal/model/useSubmitGraduationUser.ts b/apps/graduate/src/admin/widgets/StudentAddModal/model/useSubmitGraduationUser.ts index 153782ce..17129987 100644 --- a/apps/graduate/src/admin/widgets/StudentAddModal/model/useSubmitGraduationUser.ts +++ b/apps/graduate/src/admin/widgets/StudentAddModal/model/useSubmitGraduationUser.ts @@ -18,12 +18,14 @@ export function useSubmitGraduationUser({ onSuccess }: Options = {}) { const singleMutation = useMutation({ mutationFn: submitGraduationUser, + meta: { suppressErrorToast: true }, onSuccess: () => queryClient.invalidateQueries({ queryKey: graduationUsersKeys.all }), }); const batchMutation = useMutation({ mutationFn: submitGraduationUsersBatch, + meta: { suppressErrorToast: true }, onSuccess: () => queryClient.invalidateQueries({ queryKey: graduationUsersKeys.all }), }); diff --git a/apps/graduate/src/admin/widgets/StudentAddModal/ui/StudentAddModal.tsx b/apps/graduate/src/admin/widgets/StudentAddModal/ui/StudentAddModal.tsx index 8cf76175..577d46fc 100644 --- a/apps/graduate/src/admin/widgets/StudentAddModal/ui/StudentAddModal.tsx +++ b/apps/graduate/src/admin/widgets/StudentAddModal/ui/StudentAddModal.tsx @@ -1,7 +1,8 @@ -import { Col, Divider, message, Modal, Row } from 'antd'; +import { Col, Divider, Modal, Row } from 'antd'; import { useState } from 'react'; import { modalStyles } from '~/shared/config'; +import { notifyError } from '~/shared/utils'; import { ModeCard } from './ModeCard'; import StudentAddMultiple from './StudentAddMultiple'; @@ -28,9 +29,7 @@ export default function StudentAddModal({ open, onClose }: Props) { await submitSingle(payload); onClose(); } catch (error) { - message.error( - error instanceof Error ? error.message : '학생 추가에 실패했습니다.', - ); + notifyError(error, '학생 추가에 실패했습니다.'); } }; @@ -41,9 +40,7 @@ export default function StudentAddModal({ open, onClose }: Props) { await submitBatch(rows); onClose(); } catch (error) { - message.error( - error instanceof Error ? error.message : '학생들 추가에 실패했습니다.', - ); + notifyError(error, '학생들 추가에 실패했습니다.'); } }; diff --git a/apps/graduate/src/admin/widgets/Table/model/useAdminDownload.ts b/apps/graduate/src/admin/widgets/Table/model/useAdminDownload.ts index 1136680e..e3ebe0c3 100644 --- a/apps/graduate/src/admin/widgets/Table/model/useAdminDownload.ts +++ b/apps/graduate/src/admin/widgets/Table/model/useAdminDownload.ts @@ -1,22 +1,18 @@ import { useCallback } from 'react'; import { GraduationType } from '~/shared/constants'; -import { useToast } from '~/shared/hooks'; +import { notifyError } from '~/shared/utils'; import { excelDownload } from '../utils/excelDownload'; export function useAdminDownload(graduationType?: GraduationType) { - const { toast } = useToast(); - const handleDownload = useCallback(async () => { try { await excelDownload(graduationType); } catch (error) { - toast.error( - error instanceof Error ? error.message : '다운로드에 실패했습니다.', - ); + notifyError(error, '다운로드에 실패했습니다.'); } - }, [graduationType, toast]); + }, [graduationType]); return { handleDownload }; } diff --git a/apps/graduate/src/client/pages/apply/api/submitConfirmEmail.ts b/apps/graduate/src/client/pages/apply/api/submitConfirmEmail.ts index 86ea9e99..371691ac 100644 --- a/apps/graduate/src/client/pages/apply/api/submitConfirmEmail.ts +++ b/apps/graduate/src/client/pages/apply/api/submitConfirmEmail.ts @@ -16,6 +16,7 @@ const submitConfirmEmail = async (email: string) => { export const useSubmitConfirmEmail = () => { return useMutation({ mutationFn: submitConfirmEmail, + meta: { suppressErrorToast: true }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: userKeys.graduationStatus() }); }, diff --git a/apps/graduate/src/client/pages/apply/ui/ApplyConfirmPage.tsx b/apps/graduate/src/client/pages/apply/ui/ApplyConfirmPage.tsx index 568a1337..22317fd6 100644 --- a/apps/graduate/src/client/pages/apply/ui/ApplyConfirmPage.tsx +++ b/apps/graduate/src/client/pages/apply/ui/ApplyConfirmPage.tsx @@ -1,9 +1,9 @@ import { useNavigate } from '@tanstack/react-router'; -import { message } from 'antd'; import { Check } from 'lucide-react'; import { useState } from 'react'; import { ROUTE } from '~/shared/constants'; +import { notifyError, notifySuccess, notifyWarning } from '~/shared/utils'; import { useSubmitConfirmEmail } from '../api/submitConfirmEmail'; import * as styles from '../styles/ApplyConfirmPage.css'; @@ -16,22 +16,25 @@ export default function ApplyConfirmPage() { const handleComplete = () => { if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - message.error('올바른 이메일 형식을 입력해주세요.'); + notifyWarning('올바른 이메일 형식을 입력해주세요.'); return; } if (email) { submitConfirmEmail(email, { onSuccess: () => { - message.success(`${email}로 알림 설정을 완료했습니다.`); + notifySuccess(`${email}로 알림 설정을 완료했습니다.`); navigate({ to: ROUTE.HOME }); }, - onError: () => { - message.error('이메일 등록에 실패했습니다. 다시 시도해주세요.'); + onError: error => { + notifyError( + error, + '이메일 등록에 실패했습니다. 다시 시도해주세요.', + ); }, }); } else { - message.success('신청이 완료되었습니다.'); + notifySuccess('신청이 완료되었습니다.'); navigate({ to: ROUTE.HOME }); } }; diff --git a/apps/graduate/src/client/pages/apply/ui/ApplyDrawer.tsx b/apps/graduate/src/client/pages/apply/ui/ApplyDrawer.tsx index f2393a0e..2599dec8 100644 --- a/apps/graduate/src/client/pages/apply/ui/ApplyDrawer.tsx +++ b/apps/graduate/src/client/pages/apply/ui/ApplyDrawer.tsx @@ -22,7 +22,7 @@ export default function ApplyDrawer({ }: ApplyDrawerProps) { const router = useRouter(); const handleCancel = () => { - router.navigate({ to: ROUTE.APPLY, params: { confirm: false } }); + router.navigate({ to: ROUTE.APPLY, search: { confirm: false } }); }; const { index, step } = useStep({ selectedOption, confirm }); diff --git a/apps/graduate/src/client/pages/apply/ui/ApplyPage.tsx b/apps/graduate/src/client/pages/apply/ui/ApplyPage.tsx index c605aa48..7cb93aaa 100644 --- a/apps/graduate/src/client/pages/apply/ui/ApplyPage.tsx +++ b/apps/graduate/src/client/pages/apply/ui/ApplyPage.tsx @@ -1,4 +1,4 @@ -import { Link, useNavigate, useSearch } from '@tanstack/react-router'; +import { useNavigate, useSearch } from '@tanstack/react-router'; import { Button } from 'antd'; import { useState } from 'react'; @@ -18,9 +18,7 @@ import { vars } from '~/vars.css'; export default function ApplyPage() { const navigate = useNavigate(); - const { confirm } = useSearch({ from: '/apply' }) as { - confirm: boolean; - }; + const { confirm } = useSearch({ from: '/apply' }); const [selectedOption, setSelectedOption] = useState( GRADUATION_TYPE.THESIS, ); @@ -35,6 +33,10 @@ export default function ApplyPage() { }); }; + const openConfirmDrawer = () => { + navigate({ to: ROUTE.APPLY, search: { confirm: true } }); + }; + return (
@@ -86,15 +88,15 @@ export default function ApplyPage() { isSubmitting={isSubmitting} /> - - - + 제출하기 +
); } diff --git a/apps/graduate/src/client/pages/auth/login/api/submitLogin.ts b/apps/graduate/src/client/pages/auth/login/api/submitLogin.ts index a2787d81..9b5fbc27 100644 --- a/apps/graduate/src/client/pages/auth/login/api/submitLogin.ts +++ b/apps/graduate/src/client/pages/auth/login/api/submitLogin.ts @@ -23,6 +23,7 @@ export const useSubmitLogin = ({ }) => { return useMutation({ mutationFn: submitLogin, + meta: { suppressErrorToast: true }, onSuccess: response => { setAccessToken(response.accessToken); setRefreshToken(response.refreshToken); diff --git a/apps/graduate/src/client/pages/certification/api/submitCertificate.ts b/apps/graduate/src/client/pages/certification/api/submitCertificate.ts index eb5eca55..89b56cd2 100644 --- a/apps/graduate/src/client/pages/certification/api/submitCertificate.ts +++ b/apps/graduate/src/client/pages/certification/api/submitCertificate.ts @@ -16,6 +16,7 @@ const submitCertificate = async (data: FormData) => { export const useSubmitCertificate = () => { return useMutation({ mutationFn: submitCertificate, + meta: { suppressErrorToast: true }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: studentKeys.files() }); queryClient.invalidateQueries({ queryKey: userKeys.graduationStatus() }); diff --git a/apps/graduate/src/client/pages/certification/ui/FileUploadDragger.tsx b/apps/graduate/src/client/pages/certification/ui/FileUploadDragger.tsx index 855e0d4f..5ab43633 100644 --- a/apps/graduate/src/client/pages/certification/ui/FileUploadDragger.tsx +++ b/apps/graduate/src/client/pages/certification/ui/FileUploadDragger.tsx @@ -1,6 +1,8 @@ -import { message, Upload, UploadProps } from 'antd'; +import { Upload, UploadProps } from 'antd'; import { InboxIcon } from 'lucide-react'; +import { notifyError, notifySuccess, notifyWarning } from '~/shared/utils'; + import { useSubmitCertificate } from '../api/submitCertificate'; const { Dragger } = Upload; @@ -18,12 +20,15 @@ export const FileUploadDragger = () => { const response = await submitCertificate(formData); onSuccess?.(response); - message.success( + notifySuccess( `${(file as File).name} 파일이 성공적으로 업로드되었습니다.`, ); } catch (error) { onError?.(error as Error); - message.error(`${(file as File).name} 파일 업로드에 실패했습니다.`); + notifyError( + error, + `${(file as File).name} 파일 업로드에 실패했습니다.`, + ); } }; @@ -32,7 +37,7 @@ export const FileUploadDragger = () => { file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'); if (!isPdf) { - alert( + notifyWarning( `${file.name}은(는) PDF 파일이 아닙니다. PDF 파일만 업로드할 수 있습니다.`, ); return false; diff --git a/apps/graduate/src/client/pages/home/api/fetchGraduationStatus.ts b/apps/graduate/src/client/pages/home/api/fetchGraduationStatus.ts index 46d0ab7c..1a3fc7c4 100644 --- a/apps/graduate/src/client/pages/home/api/fetchGraduationStatus.ts +++ b/apps/graduate/src/client/pages/home/api/fetchGraduationStatus.ts @@ -17,5 +17,7 @@ export const useFetchGraduationStatus = () => { return useQuery({ queryKey: userKeys.graduationStatus(), queryFn: () => fetchGraduationStatus(), + retry: false, + meta: { suppressErrorToast: true }, }); }; diff --git a/apps/graduate/src/client/pages/home/hooks/useHomePageData.ts b/apps/graduate/src/client/pages/home/hooks/useHomePageData.ts index 1c72e84d..986d40d8 100644 --- a/apps/graduate/src/client/pages/home/hooks/useHomePageData.ts +++ b/apps/graduate/src/client/pages/home/hooks/useHomePageData.ts @@ -1,5 +1,4 @@ import { WORKFLOW_STAGE_INFO } from '~/shared/constants/graduationWorkflow'; -import { WORKFLOW_STAGE } from '~/shared/types/graduation'; import { useFetchGraduationStatus } from '../api/fetchGraduationStatus'; @@ -7,18 +6,42 @@ export const useHomePageData = () => { const { data: graduationStatus, isLoading: graduationStatusLoading, + isError: graduationStatusIsError, error: graduationStatusError, } = useFetchGraduationStatus(); - const currentStatus = - graduationStatus?.status ?? WORKFLOW_STAGE.TYPE_NOT_SELECTED; + if (graduationStatusLoading) { + return { + graduationStatusData: graduationStatus, + graduationStatusLoading: true, + graduationStatusError: undefined, + currentStatus: undefined, + title: undefined, + description: undefined, + button: undefined, + }; + } + if (graduationStatusIsError || !graduationStatus) { + return { + graduationStatusData: undefined, + graduationStatusLoading: false, + graduationStatusError, + currentStatus: undefined, + title: '졸업 정보를 확인할 수 없어요.', + description: + '졸업 대상자로 등록되지 않았거나 정보를 불러올 수 없어요.\n담당자에게 문의해 주세요.', + button: undefined, + }; + } + + const currentStatus = graduationStatus.status; const statusTextData = WORKFLOW_STAGE_INFO[currentStatus]; return { graduationStatusData: graduationStatus, - graduationStatusLoading, - graduationStatusError, + graduationStatusLoading: false, + graduationStatusError: undefined, currentStatus, title: statusTextData?.title, description: statusTextData?.description, diff --git a/apps/graduate/src/client/pages/thesis/api/submitThesis.ts b/apps/graduate/src/client/pages/thesis/api/submitThesis.ts index 0036f229..93108458 100644 --- a/apps/graduate/src/client/pages/thesis/api/submitThesis.ts +++ b/apps/graduate/src/client/pages/thesis/api/submitThesis.ts @@ -16,6 +16,7 @@ const submitThesis = async (data: FormData) => { export const useSubmitThesis = () => { return useMutation({ mutationFn: submitThesis, + meta: { suppressErrorToast: true }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: studentKeys.files() }); queryClient.invalidateQueries({ queryKey: userKeys.graduationStatus() }); diff --git a/apps/graduate/src/client/pages/thesis/ui/ThesisFileUploadDragger.tsx b/apps/graduate/src/client/pages/thesis/ui/ThesisFileUploadDragger.tsx index b408bbf3..beb0ff55 100644 --- a/apps/graduate/src/client/pages/thesis/ui/ThesisFileUploadDragger.tsx +++ b/apps/graduate/src/client/pages/thesis/ui/ThesisFileUploadDragger.tsx @@ -1,6 +1,8 @@ -import { message, Upload, UploadProps } from 'antd'; +import { Upload, UploadProps } from 'antd'; import { InboxIcon } from 'lucide-react'; +import { notifyError, notifySuccess, notifyWarning } from '~/shared/utils'; + import { useSubmitThesis } from '../api/submitThesis'; const { Dragger } = Upload; @@ -28,12 +30,15 @@ export const ThesisFileUploadDragger = ({ const response = await submitThesis(formData); onSuccess?.(response); - message.success( + notifySuccess( `${(file as File).name} 파일이 성공적으로 업로드되었습니다.`, ); } catch (error) { onError?.(error as Error); - message.error(`${(file as File).name} 파일 업로드에 실패했습니다.`); + notifyError( + error, + `${(file as File).name} 파일 업로드에 실패했습니다.`, + ); } }; @@ -42,7 +47,7 @@ export const ThesisFileUploadDragger = ({ file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'); if (!isPdf) { - alert( + notifyWarning( `${file.name}은(는) PDF 파일이 아닙니다. PDF 파일만 업로드할 수 있습니다.`, ); return false; diff --git a/apps/graduate/src/client/pages/thesis/ui/ThesisPage.tsx b/apps/graduate/src/client/pages/thesis/ui/ThesisPage.tsx index a343b417..33d887d7 100644 --- a/apps/graduate/src/client/pages/thesis/ui/ThesisPage.tsx +++ b/apps/graduate/src/client/pages/thesis/ui/ThesisPage.tsx @@ -8,9 +8,7 @@ import { SubmissionButtons } from '~/client/pages/certification/ui/SubmissionBut import * as styles from '~/client/shared/styles/SubmissionPage.css'; export default function ThesisPage() { - const { type } = useSearch({ from: '/thesis' }) as { - type: 'midreport' | 'finalreport'; - }; + const { type } = useSearch({ from: '/thesis' }); return (
diff --git a/apps/graduate/src/routes/apply.tsx b/apps/graduate/src/routes/apply.tsx index b67ced37..b1ca2aee 100644 --- a/apps/graduate/src/routes/apply.tsx +++ b/apps/graduate/src/routes/apply.tsx @@ -1,11 +1,17 @@ import { createFileRoute, redirect } from '@tanstack/react-router'; -import { message } from 'antd'; -import { checkPageAccess } from '~/shared/utils'; +import { checkPageAccess, notifyWarning } from '~/shared/utils'; import { ApplyPage } from '~/client/pages/apply'; +type ApplySearch = { + confirm: boolean; +}; + export const Route = createFileRoute('/apply')({ + validateSearch: (search: Record): ApplySearch => ({ + confirm: search.confirm === true || search.confirm === 'true', + }), beforeLoad: async ({ context }) => { if (context.auth.isAdmin) { return; @@ -14,7 +20,7 @@ export const Route = createFileRoute('/apply')({ const { canAccess, reason } = await checkPageAccess('apply'); if (!canAccess) { if (reason) { - message.warning(reason); + notifyWarning(reason); } throw redirect({ to: '/', diff --git a/apps/graduate/src/routes/certification.tsx b/apps/graduate/src/routes/certification.tsx index 4662edad..008984d0 100644 --- a/apps/graduate/src/routes/certification.tsx +++ b/apps/graduate/src/routes/certification.tsx @@ -1,7 +1,6 @@ import { createFileRoute, redirect } from '@tanstack/react-router'; -import { message } from 'antd'; -import { checkPageAccess } from '~/shared/utils'; +import { checkPageAccess, notifyWarning } from '~/shared/utils'; import { CertificationAdminPage } from '~/admin/pages/certification'; import { CertificationPage } from '~/client/pages/certification'; @@ -15,7 +14,7 @@ export const Route = createFileRoute('/certification')({ const { canAccess, reason } = await checkPageAccess('certification'); if (!canAccess) { if (reason) { - message.warning(reason); + notifyWarning(reason); } throw redirect({ to: '/', diff --git a/apps/graduate/src/routes/thesis.tsx b/apps/graduate/src/routes/thesis.tsx index f386f860..14339c87 100644 --- a/apps/graduate/src/routes/thesis.tsx +++ b/apps/graduate/src/routes/thesis.tsx @@ -1,12 +1,18 @@ import { createFileRoute, redirect } from '@tanstack/react-router'; -import { message } from 'antd'; -import { checkPageAccess } from '~/shared/utils'; +import { checkPageAccess, notifyWarning } from '~/shared/utils'; import { ThesisAdminPage } from '~/admin/pages/thesis'; import { ThesisPage } from '~/client/pages/thesis'; +type ThesisSearch = { + type: 'midreport' | 'finalreport'; +}; + export const Route = createFileRoute('/thesis')({ + validateSearch: (search: Record): ThesisSearch => ({ + type: search.type === 'finalreport' ? 'finalreport' : 'midreport', + }), beforeLoad: async ({ context }) => { if (context.auth.isAdmin) { return; @@ -15,7 +21,7 @@ export const Route = createFileRoute('/thesis')({ const { canAccess, reason } = await checkPageAccess('thesis'); if (!canAccess) { if (reason) { - message.warning(reason); + notifyWarning(reason); } throw redirect({ to: '/', diff --git a/apps/graduate/src/shared/hooks/useNoticeDetail.ts b/apps/graduate/src/shared/hooks/useNoticeDetail.ts index c4d277e1..59d39e38 100644 --- a/apps/graduate/src/shared/hooks/useNoticeDetail.ts +++ b/apps/graduate/src/shared/hooks/useNoticeDetail.ts @@ -23,5 +23,6 @@ export function useNoticeDetail(noticeId: number) { return transformNoticeDetailResponse(response.data); }, enabled: !!noticeId, + meta: { suppressErrorToast: true }, }); } diff --git a/apps/graduate/src/shared/hooks/useToast.ts b/apps/graduate/src/shared/hooks/useToast.ts index fa498f41..6d59e985 100644 --- a/apps/graduate/src/shared/hooks/useToast.ts +++ b/apps/graduate/src/shared/hooks/useToast.ts @@ -1,6 +1,8 @@ import { App } from 'antd'; import type { ReactNode } from 'react'; +import parseError from '../utils/error'; + export interface ConfirmConfig { title: string; content?: string; @@ -24,6 +26,11 @@ export function useToast() { error: message.error, warning: message.warning, info: message.info, + /** API/알 수 없는 오류를 사용자용 문구로 토스트 */ + fromError: (error: unknown, fallbackMessage?: string) => { + const text = fallbackMessage ?? parseError(error).message; + message.error({ content: text, duration: 4 }); + }, }; const confirm = (config: ConfirmConfig) => { diff --git a/apps/graduate/src/shared/types/tanstack-query.d.ts b/apps/graduate/src/shared/types/tanstack-query.d.ts new file mode 100644 index 00000000..1f5ae653 --- /dev/null +++ b/apps/graduate/src/shared/types/tanstack-query.d.ts @@ -0,0 +1,12 @@ +import '@tanstack/react-query'; + +declare module '@tanstack/react-query' { + interface Register { + queryMeta: { + suppressErrorToast?: boolean; + }; + mutationMeta: { + suppressErrorToast?: boolean; + }; + } +} diff --git a/apps/graduate/src/shared/utils/index.ts b/apps/graduate/src/shared/utils/index.ts index ca18ee24..d2eeb88d 100644 --- a/apps/graduate/src/shared/utils/index.ts +++ b/apps/graduate/src/shared/utils/index.ts @@ -6,3 +6,9 @@ export { default as parseError } from './error'; export { default as logout } from './logout'; export { default as queryClient } from './queryClient'; export { default as router } from './router'; +export { + notifyError, + notifyInfo, + notifySuccess, + notifyWarning, +} from './toastFeedback'; diff --git a/apps/graduate/src/shared/utils/queryClient.ts b/apps/graduate/src/shared/utils/queryClient.ts index c56c51e7..f2fcbe87 100644 --- a/apps/graduate/src/shared/utils/queryClient.ts +++ b/apps/graduate/src/shared/utils/queryClient.ts @@ -1,6 +1,24 @@ -import { QueryClient } from '@tanstack/react-query'; +import { + MutationCache, + QueryCache, + QueryClient, +} from '@tanstack/react-query'; + +import { notifyError } from './toastFeedback'; const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: (error, query) => { + if (query.meta?.suppressErrorToast) return; + notifyError(error); + }, + }), + mutationCache: new MutationCache({ + onError: (error, _variables, _context, mutation) => { + if (mutation.meta?.suppressErrorToast) return; + notifyError(error); + }, + }), defaultOptions: { queries: { retry: 1, @@ -13,4 +31,5 @@ const queryClient = new QueryClient({ }, }, }); + export default queryClient; diff --git a/apps/graduate/src/shared/utils/routeGuards.ts b/apps/graduate/src/shared/utils/routeGuards.ts index 03f5ce87..e5c6bf34 100644 --- a/apps/graduate/src/shared/utils/routeGuards.ts +++ b/apps/graduate/src/shared/utils/routeGuards.ts @@ -1,7 +1,8 @@ import dayjs from 'dayjs'; import isBetween from 'dayjs/plugin/isBetween'; -import { END_POINT } from '~/shared/constants'; +import { END_POINT, SCHEDULE } from '~/shared/constants'; +import type { SubmissionType } from '~/shared/types'; import { WORKFLOW_STAGE, type WorkflowStage } from '~/shared/types/graduation'; import { get } from '../api'; @@ -9,30 +10,26 @@ import { get } from '../api'; dayjs.extend(isBetween); /** - * 졸업 상태 조회 + * 졸업 상태 조회. 미등록·네트워크 오류 등으로 조회에 실패하면 null. */ -export async function fetchUserStatus(): Promise { +export async function fetchUserStatus(): Promise { try { const response = await get<{ status: WorkflowStage }>({ request: END_POINT.USER.GRADUATION_STATUS, }); return response.data.status; } catch { - alert('졸업 상태를 불러올 수 없습니다.'); - throw new Error('졸업 상태를 불러올 수 없습니다.'); + return null; } } -/** - * 특정 단계의 일정 조회 - */ -export async function fetchScheduleForStage( - stage: WorkflowStage, +async function fetchScheduleBySubmissionType( + submissionType: SubmissionType, ): Promise<{ startDate: string; endDate: string } | null> { try { const response = await get<{ contents: Array<{ - submissionType: WorkflowStage; + submissionType: SubmissionType; startDate: string; endDate: string; }>; @@ -41,7 +38,7 @@ export async function fetchScheduleForStage( }); const schedule = response.data.contents.find( - s => s.submissionType === stage, + s => s.submissionType === submissionType, ); return schedule @@ -53,143 +50,133 @@ export async function fetchScheduleForStage( } /** - * 현재 시간이 일정 기간 내에 있는지 확인 + * 현재 시각이 일정 구간 안에 있는지 (양끝 포함) */ export function isWithinSchedule(startDate: string, endDate: string): boolean { const now = dayjs(); - - if (!now.isBetween(dayjs(startDate), dayjs(endDate), null, '[]')) { - alert(`${startDate} ~ ${endDate} 일정 기간이 아닙니다.`); - return false; - } - - return true; + return now.isBetween(dayjs(startDate), dayjs(endDate), null, '[]'); } -/** - * Apply 페이지 접근 가능 여부 확인 - * - 졸업 방식 미선택 상태일 때만 접근 가능 - */ -export async function canAccessApplyPage(): Promise { - const status = await fetchUserStatus(); - return status === WORKFLOW_STAGE.TYPE_NOT_SELECTED || status === null; +function formatScheduleRange(startDate: string, endDate: string): string { + return `${startDate} ~ ${endDate}`; } /** - * Certification 페이지 접근 가능 여부 확인 - * - 자격증 제출 대기 상태일 때만 접근 가능 - * - 자격증 제출 일정 기간 내에만 접근 가능 + * 페이지 접근 권한 체크 및 리다이렉트 정보 반환 */ -export async function canAccessCertificationPage(): Promise { - const status = await fetchUserStatus(); +export async function checkPageAccess( + page: 'apply' | 'certification' | 'thesis', +): Promise<{ canAccess: boolean; reason?: string }> { + try { + const status = await fetchUserStatus(); + + if (status === null) { + return { + canAccess: false, + reason: + '졸업 대상자로 등록되지 않았거나 졸업 정보를 불러올 수 없습니다. 담당자에게 문의해 주세요.', + }; + } - if (status !== WORKFLOW_STAGE.CERTIFICATE_PENDING) { - alert('자격증 제출 단계가 아닙니다.'); - return false; - } + switch (page) { + case 'apply': { + const eligibleForApply = + status === WORKFLOW_STAGE.TYPE_NOT_SELECTED || + status === WORKFLOW_STAGE.PROFESSOR_NOT_ASSIGNED; + + if (!eligibleForApply) { + return { + canAccess: false, + reason: '졸업 방식 선택 단계가 아닙니다.', + }; + } - return true; + const schedule = await fetchScheduleBySubmissionType( + SCHEDULE.SUBMITTED, + ); - // const schedule = await fetchScheduleForStage( - // WORKFLOW_STAGE.CERTIFICATE_PENDING, - // ); + if (!schedule) { + return { + canAccess: false, + reason: '졸업 요건 취득 방식 신청 일정을 불러올 수 없습니다.', + }; + } - // if (!schedule) { - // alert('사용자 일정을 불러올 수 없습니다.'); - // return false; - // } + if (!isWithinSchedule(schedule.startDate, schedule.endDate)) { + return { + canAccess: false, + reason: `졸업 요건 취득 방식 신청 기간이 아닙니다. (${formatScheduleRange(schedule.startDate, schedule.endDate)})`, + }; + } - // return isWithinSchedule(schedule.startDate, schedule.endDate); -} + return { canAccess: true }; + } -/** - * Thesis 페이지 접근 가능 여부 확인 - * - 중간/최종 논문 제출 대기 상태일 때만 접근 가능 - * - 해당 논문 제출 일정 기간 내에만 접근 가능 - */ -export async function canAccessThesisPage(): Promise { - const status = await fetchUserStatus(); - - if (status === WORKFLOW_STAGE.MID_THESIS_PENDING) { - // const schedule = await fetchScheduleForStage( - // WORKFLOW_STAGE.MID_THESIS_PENDING, - // ); - // if (!schedule) { - // alert('사용자 일정을 불러올 수 없습니다.'); - // return false; - // } - // return isWithinSchedule(schedule.startDate, schedule.endDate); - return true; - } + case 'certification': { + if (status !== WORKFLOW_STAGE.CERTIFICATE_PENDING) { + return { + canAccess: false, + reason: '자격증 제출 단계가 아닙니다.', + }; + } - if (status === WORKFLOW_STAGE.FINAL_THESIS_PENDING) { - // const schedule = await fetchScheduleForStage( - // WORKFLOW_STAGE.FINAL_THESIS_PENDING, - // ); - // if (!schedule) { - // alert('사용자 일정을 불러올 수 없습니다.'); - // return false; - // } - // return isWithinSchedule(schedule.startDate, schedule.endDate); - return true; - } + const schedule = await fetchScheduleBySubmissionType( + SCHEDULE.CERTIFICATE, + ); - return false; -} + if (!schedule) { + return { + canAccess: false, + reason: '자격증 제출 일정을 불러올 수 없습니다.', + }; + } -/** - * 페이지 접근 권한 체크 및 리다이렉트 정보 반환 - */ -export async function checkPageAccess( - page: 'apply' | 'certification' | 'thesis', -): Promise<{ canAccess: boolean; reason?: string }> { - try { - let canAccess = false; - let reason = ''; + if (!isWithinSchedule(schedule.startDate, schedule.endDate)) { + return { + canAccess: false, + reason: `자격증 제출 기간이 아닙니다. (${formatScheduleRange(schedule.startDate, schedule.endDate)})`, + }; + } - switch (page) { - case 'apply': - canAccess = await canAccessApplyPage(); - if (!canAccess) { - reason = '졸업 방식 선택 단계가 아닙니다.'; + return { canAccess: true }; + } + + case 'thesis': { + let submissionType: SubmissionType | null = null; + + if (status === WORKFLOW_STAGE.MID_THESIS_PENDING) { + submissionType = SCHEDULE.MIDTHESIS; + } else if (status === WORKFLOW_STAGE.FINAL_THESIS_PENDING) { + submissionType = SCHEDULE.FINALTHESIS; } - break; - - case 'certification': - canAccess = await canAccessCertificationPage(); - if (!canAccess) { - const status = await fetchUserStatus(); - if (status !== WORKFLOW_STAGE.CERTIFICATE_PENDING) { - reason = '자격증 제출 단계가 아닙니다.'; - } else { - reason = '자격증 제출 기간이 아닙니다.'; - } + + if (!submissionType) { + return { + canAccess: false, + reason: '논문 제출 단계가 아닙니다.', + }; } - break; - - case 'thesis': - canAccess = await canAccessThesisPage(); - if (!canAccess) { - const status = await fetchUserStatus(); - if ( - status !== WORKFLOW_STAGE.MID_THESIS_PENDING && - status !== WORKFLOW_STAGE.FINAL_THESIS_PENDING - ) { - reason = '논문 제출 단계가 아닙니다.'; - } else { - reason = '논문 제출 기간이 아닙니다.'; - } + + const schedule = await fetchScheduleBySubmissionType(submissionType); + + if (!schedule) { + return { + canAccess: false, + reason: '논문 제출 일정을 불러올 수 없습니다.', + }; } - break; - } - if (!canAccess) { - alert(reason); - } + if (!isWithinSchedule(schedule.startDate, schedule.endDate)) { + return { + canAccess: false, + reason: `논문 제출 기간이 아닙니다. (${formatScheduleRange(schedule.startDate, schedule.endDate)})`, + }; + } - return { canAccess, reason }; + return { canAccess: true }; + } + } } catch { - alert('접근 권한을 확인할 수 없습니다.'); return { canAccess: false, reason: '접근 권한을 확인할 수 없습니다.', diff --git a/apps/graduate/src/shared/utils/toastFeedback.ts b/apps/graduate/src/shared/utils/toastFeedback.ts new file mode 100644 index 00000000..c526117d --- /dev/null +++ b/apps/graduate/src/shared/utils/toastFeedback.ts @@ -0,0 +1,27 @@ +import { message } from 'antd'; + +import parseError from './error'; + +/** 사용자 피드백용 토스트(antd message) 공통 설정 */ +const DURATION_SEC = 4; + +/** + * React 컴포넌트 밖(QueryClient, 라우터 beforeLoad 등)에서도 사용 가능한 토스트. + * Ant Design `App` 루트 하위에서 동작합니다. + */ +export function notifyError(error: unknown, fallbackMessage?: string): void { + const text = fallbackMessage ?? parseError(error).message; + message.error({ content: text, duration: DURATION_SEC }); +} + +export function notifyWarning(text: string): void { + message.warning({ content: text, duration: DURATION_SEC }); +} + +export function notifySuccess(text: string): void { + message.success({ content: text, duration: DURATION_SEC }); +} + +export function notifyInfo(text: string): void { + message.info({ content: text, duration: DURATION_SEC }); +}