diff --git a/apps/graduate/src/pages/admin/certification/ui/CertificationAdminPage.tsx b/apps/graduate/src/pages/admin/certification/ui/CertificationAdminPage.tsx index 9a7c69f7..3c9de50c 100644 --- a/apps/graduate/src/pages/admin/certification/ui/CertificationAdminPage.tsx +++ b/apps/graduate/src/pages/admin/certification/ui/CertificationAdminPage.tsx @@ -2,15 +2,8 @@ import { useState } from 'react'; import { DataTable, Header, Pagination, Toolbar } from '~/shared/components'; import { - APPROVE_ALERT, - APPROVE_CONFIRM_TITLE, - APPROVE_EMPTY, - APPROVE_FAILED, - APPROVE_SUCCESS, -} from '~/shared/components/Toolbar/toolbarTexts'; -import { + useApproveGraduationUsers, useFetchGraduationUsers, - useUpdateGraduationUsersBatchApprove, useToast, } from '~/shared/hooks'; import { downloadGraduationUsersExcel } from '~/shared/utils'; @@ -24,17 +17,13 @@ export default function CertificationAdminPage() { const [pageSize, setPageSize] = useState(10); const [query, setQuery] = useState(''); const [selectedIds, setSelectedIds] = useState([]); - const { toast, confirm } = useToast(); + const { toast } = useToast(); const resetSelection = () => { setSelectedIds([]); setPage(1); }; - const { approveGraduationUsers } = useUpdateGraduationUsersBatchApprove({ - onSuccess: resetSelection, - }); - const { data, isLoading } = useFetchGraduationUsers({ page: page - 1, size: pageSize, @@ -42,6 +31,26 @@ export default function CertificationAdminPage() { graduationType: 'CERTIFICATE', }); + const { handleApproveSelected } = useApproveGraduationUsers({ + items: data?.contents ?? [], + selectedIds, + getId: user => user.id, + getLabel: user => `${user.studentId} ${user.name}`, + status: { + isSubmitted: user => { + const status = user.status; + if (!status || status.type !== 'CERTIFICATE') return false; + return status.submitted; + }, + isApproved: user => { + const status = user.status; + if (!status || status.type !== 'CERTIFICATE') return false; + return status.approval; + }, + }, + onSuccess: resetSelection, + }); + const rows: CertRow[] = data ? data.contents.map((user, idx) => { const certStatus = @@ -83,27 +92,6 @@ export default function CertificationAdminPage() { ); }; - const handleApproveSelected = () => { - if (selectedIds.length === 0) { - toast.warning(APPROVE_EMPTY); - return; - } - confirm({ - title: APPROVE_CONFIRM_TITLE, - content: APPROVE_ALERT, - okText: '승인', - cancelText: '취소', - onOk: async () => { - try { - await approveGraduationUsers(selectedIds); - toast.success(APPROVE_SUCCESS); - } catch (error) { - toast.error(error instanceof Error ? error.message : APPROVE_FAILED); - } - }, - }); - }; - const handleDownloadExcel = async () => { try { await downloadGraduationUsersExcel('CERTIFICATE'); diff --git a/apps/graduate/src/pages/admin/thesis/ui/ThesisAdminPage.tsx b/apps/graduate/src/pages/admin/thesis/ui/ThesisAdminPage.tsx index 5439494e..29e29ab7 100644 --- a/apps/graduate/src/pages/admin/thesis/ui/ThesisAdminPage.tsx +++ b/apps/graduate/src/pages/admin/thesis/ui/ThesisAdminPage.tsx @@ -2,15 +2,8 @@ import { useState } from 'react'; import { DataTable, Header, Pagination, Toolbar } from '~/shared/components'; import { - APPROVE_ALERT, - APPROVE_CONFIRM_TITLE, - APPROVE_EMPTY, - APPROVE_FAILED, - APPROVE_SUCCESS, -} from '~/shared/components/Toolbar/toolbarTexts'; -import { + useApproveGraduationUsers, useFetchGraduationUsers, - useUpdateGraduationUsersBatchApprove, useToast, } from '~/shared/hooks'; import { downloadGraduationUsersExcel } from '~/shared/utils'; @@ -24,17 +17,13 @@ export default function ThesisAdminPage() { const [pageSize, setPageSize] = useState(10); const [query, setQuery] = useState(''); const [selectedIds, setSelectedIds] = useState([]); - const { toast, confirm } = useToast(); + const { toast } = useToast(); const resetSelection = () => { setSelectedIds([]); setPage(1); }; - const { approveGraduationUsers } = useUpdateGraduationUsersBatchApprove({ - onSuccess: resetSelection, - }); - const { data, isLoading } = useFetchGraduationUsers({ page: page - 1, size: pageSize, @@ -42,6 +31,26 @@ export default function ThesisAdminPage() { graduationType: 'THESIS', }); + const { handleApproveSelected } = useApproveGraduationUsers({ + items: data?.contents ?? [], + selectedIds, + getId: user => user.id, + getLabel: user => `${user.studentId} ${user.name}`, + status: { + isSubmitted: user => { + const status = user.status; + if (!status || status.type !== 'THESIS') return false; + return status.midThesis.submitted && status.finalThesis.submitted; + }, + isApproved: user => { + const status = user.status; + if (!status || status.type !== 'THESIS') return false; + return status.midThesis.approval && status.finalThesis.approval; + }, + }, + onSuccess: resetSelection, + }); + const rows: ThesisRow[] = data ? data.contents.map((user, idx) => { const thesis = user.status?.type === 'THESIS' ? user.status : null; @@ -92,27 +101,6 @@ export default function ThesisAdminPage() { ); }; - const handleApproveSelected = () => { - if (selectedIds.length === 0) { - toast.warning(APPROVE_EMPTY); - return; - } - confirm({ - title: APPROVE_CONFIRM_TITLE, - content: APPROVE_ALERT, - okText: '승인', - cancelText: '취소', - onOk: async () => { - try { - await approveGraduationUsers(selectedIds); - toast.success(APPROVE_SUCCESS); - } catch (error) { - toast.error(error instanceof Error ? error.message : APPROVE_FAILED); - } - }, - }); - }; - const handleDownloadExcel = async () => { try { await downloadGraduationUsersExcel('THESIS'); diff --git a/apps/graduate/src/shared/components/Toolbar/toolbarTexts.ts b/apps/graduate/src/shared/components/Toolbar/toolbarTexts.ts index 3ce49fbf..70d301a2 100644 --- a/apps/graduate/src/shared/components/Toolbar/toolbarTexts.ts +++ b/apps/graduate/src/shared/components/Toolbar/toolbarTexts.ts @@ -2,6 +2,16 @@ export const DELETE_ALERT = '선택한 학생을 삭제할까요?'; export const DELETE_CONFIRM_TITLE = '학생 삭제'; export const APPROVE_ALERT = '선택한 학생을 승인할까요?'; export const APPROVE_CONFIRM_TITLE = '학생 승인'; +export const APPROVE_OK_TEXT = '승인'; +export const APPROVE_CANCEL_TEXT = '취소'; export const APPROVE_EMPTY = '승인할 대상을 선택하세요.'; export const APPROVE_SUCCESS = '선택한 학생을 승인했습니다.'; export const APPROVE_FAILED = '승인에 실패했습니다.'; +export const APPROVE_NOTHING = '승인할 제출이 없습니다.'; +export const APPROVE_RESULT_TITLE = '승인 결과'; +export const APPROVE_RESULT_APPROVED = '승인된 대상'; +export const APPROVE_RESULT_NOT_APPROVED = '미승인 대상'; +export const APPROVE_RESULT_NONE = '없음'; +export const APPROVE_REASON_NOT_SUBMITTED = '미제출'; +export const APPROVE_REASON_ALREADY_APPROVED = '이미 승인'; +export const APPROVE_REASON_FAILED = '승인 실패'; diff --git a/apps/graduate/src/shared/hooks/student/index.ts b/apps/graduate/src/shared/hooks/student/index.ts index b7c0c3df..7a000b77 100644 --- a/apps/graduate/src/shared/hooks/student/index.ts +++ b/apps/graduate/src/shared/hooks/student/index.ts @@ -1,3 +1,4 @@ export { useFetchGraduationUsers } from './useFetchGraduationUsers'; +export { useApproveGraduationUsers } from './useApproveGraduationUsers'; export { useRemoveGraduationUsers } from './useRemoveGraduationUsers'; export { useUpdateGraduationUsersBatchApprove } from './useUpdateGraduationUsersBatchApprove'; diff --git a/apps/graduate/src/shared/hooks/student/useApproveGraduationUsers.ts b/apps/graduate/src/shared/hooks/student/useApproveGraduationUsers.ts new file mode 100644 index 00000000..b2d7e408 --- /dev/null +++ b/apps/graduate/src/shared/hooks/student/useApproveGraduationUsers.ts @@ -0,0 +1,178 @@ +import { createElement } from 'react'; + +import { + APPROVE_ALERT, + APPROVE_CONFIRM_TITLE, + APPROVE_OK_TEXT, + APPROVE_CANCEL_TEXT, + APPROVE_EMPTY, + APPROVE_FAILED, + APPROVE_NOTHING, + APPROVE_REASON_ALREADY_APPROVED, + APPROVE_REASON_FAILED, + APPROVE_REASON_NOT_SUBMITTED, + APPROVE_RESULT_APPROVED, + APPROVE_RESULT_NONE, + APPROVE_RESULT_NOT_APPROVED, + APPROVE_RESULT_TITLE, + APPROVE_SUCCESS, +} from '~/shared/components/Toolbar/toolbarTexts'; + +import { useToast } from '../useToast'; +import { useUpdateGraduationUsersBatchApprove } from './useUpdateGraduationUsersBatchApprove'; + +type UseApproveGraduationUsersProps = { + items: T[]; + selectedIds: number[]; + getId: (item: T) => number; + getLabel: (item: T) => string; + status: { + isSubmitted: (item: T) => boolean; + isApproved: (item: T) => boolean; + }; + onSuccess?: () => void | Promise; +}; + +type NotApprovedDetail = { + id: number; + label: string; + reason: string; +}; + +export function useApproveGraduationUsers({ + items, + selectedIds, + getId, + getLabel, + status, + onSuccess, +}: UseApproveGraduationUsersProps) { + const { toast, confirm, info } = useToast(); + const { approveGraduationUsers } = useUpdateGraduationUsersBatchApprove({ + onSuccess, + }); + + const buildResultContent = ( + approvedUsers: T[], + notApprovedDetails: NotApprovedDetail[], + ) => { + const approvedLines = + approvedUsers.length > 0 + ? approvedUsers.map(user => `- ${getLabel(user)}`) + : [APPROVE_RESULT_NONE]; + const notApprovedLines = + notApprovedDetails.length > 0 + ? notApprovedDetails.map( + item => `- ${item.label} (${item.reason})`, + ) + : [APPROVE_RESULT_NONE]; + + return [ + APPROVE_RESULT_APPROVED, + ...approvedLines, + APPROVE_RESULT_NOT_APPROVED, + ...notApprovedLines, + ] + .filter(Boolean) + .map((line, index) => createElement('div', { key: index }, line)); + }; + + const buildNotApprovedDetails = ( + notSubmitted: T[], + alreadyApproved: T[], + failed: T[] = [], + ) => [ + ...notSubmitted.map(user => ({ + id: getId(user), + label: getLabel(user), + reason: APPROVE_REASON_NOT_SUBMITTED, + })), + ...alreadyApproved.map(user => ({ + id: getId(user), + label: getLabel(user), + reason: APPROVE_REASON_ALREADY_APPROVED, + })), + ...failed.map(user => ({ + id: getId(user), + label: getLabel(user), + reason: APPROVE_REASON_FAILED, + })), + ]; + + const handleApproveSelected = () => { + if (selectedIds.length === 0) { + toast.warning(APPROVE_EMPTY); + return; + } + + const selected = items.filter(item => selectedIds.includes(getId(item))); + const notSubmitted: T[] = []; + const pending: T[] = []; + const alreadyApproved: T[] = []; + + selected.forEach(item => { + const submitted = status.isSubmitted(item); + const approved = status.isApproved(item); + + if (!submitted) { + notSubmitted.push(item); + return; + } + if (approved) { + alreadyApproved.push(item); + return; + } + pending.push(item); + }); + + if (pending.length === 0) { + info({ + title: APPROVE_RESULT_TITLE, + content: buildResultContent( + [], + buildNotApprovedDetails(notSubmitted, alreadyApproved), + ), + }); + return; + } + + confirm({ + title: APPROVE_CONFIRM_TITLE, + content: APPROVE_ALERT, + okText: APPROVE_OK_TEXT, + cancelText: APPROVE_CANCEL_TEXT, + onOk: async () => { + try { + const result = await approveGraduationUsers( + pending.map(item => getId(item)), + ); + const approvedIdSet = new Set(result.approvedIds); + const approved = pending.filter(item => + approvedIdSet.has(getId(item)), + ); + const failed = pending.filter( + item => !approvedIdSet.has(getId(item)), + ); + + info({ + title: APPROVE_RESULT_TITLE, + content: buildResultContent( + approved, + buildNotApprovedDetails(notSubmitted, alreadyApproved, failed), + ), + }); + + if (approved.length > 0) { + toast.success(APPROVE_SUCCESS); + } else { + toast.warning(APPROVE_NOTHING); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : APPROVE_FAILED); + } + }, + }); + }; + + return { handleApproveSelected }; +} diff --git a/apps/graduate/src/shared/hooks/useToast.ts b/apps/graduate/src/shared/hooks/useToast.ts index e5e2dc9b..3749eac5 100644 --- a/apps/graduate/src/shared/hooks/useToast.ts +++ b/apps/graduate/src/shared/hooks/useToast.ts @@ -1,4 +1,6 @@ import { App } from 'antd'; +import type { ReactNode } from 'react'; + export interface ConfirmConfig { title: string; @@ -9,6 +11,12 @@ export interface ConfirmConfig { cancelText?: string; } +export interface InfoConfig { + title: string; + content?: ReactNode; + okText?: string; +} + export function useToast() { const { message, modal } = App.useApp(); @@ -27,5 +35,12 @@ export function useToast() { }); }; - return { toast, confirm }; + const info = (config: InfoConfig) => { + modal.info({ + ...config, + okText: config.okText ?? '확인', + }); + }; + + return { toast, confirm, info }; } diff --git a/apps/graduate/src/widgets/StudentAddModal/model/useStudentAddMultiple.ts b/apps/graduate/src/widgets/StudentAddModal/model/useStudentAddMultiple.ts index 536dc22c..e1faa9de 100644 --- a/apps/graduate/src/widgets/StudentAddModal/model/useStudentAddMultiple.ts +++ b/apps/graduate/src/widgets/StudentAddModal/model/useStudentAddMultiple.ts @@ -1,6 +1,7 @@ import { App } from 'antd'; import { createElement, useEffect, useState } from 'react'; + import { fetchAdminUsers, fetchGraduationUsers } from '~/shared/api'; import { PROFESSORS } from '~/shared/constants/professors';