diff --git a/apps/graduate/src/admin/entities/graduation-approval/api/index.ts b/apps/graduate/src/admin/entities/graduation-approval/api/index.ts index dcc49c02..8cde91e4 100644 --- a/apps/graduate/src/admin/entities/graduation-approval/api/index.ts +++ b/apps/graduate/src/admin/entities/graduation-approval/api/index.ts @@ -1 +1,2 @@ -export * from './updateGraduationBatchApproval'; +export * from './updateGraduationApproval'; +export * from './updateGraduationDisapproval'; diff --git a/apps/graduate/src/admin/entities/graduation-approval/api/updateGraduationApproval.ts b/apps/graduate/src/admin/entities/graduation-approval/api/updateGraduationApproval.ts new file mode 100644 index 00000000..973572b0 --- /dev/null +++ b/apps/graduate/src/admin/entities/graduation-approval/api/updateGraduationApproval.ts @@ -0,0 +1,25 @@ +import { patch } from '~/shared/api'; +import { END_POINT } from '~/shared/constants'; + +export type UpdateGraduationApprovalParams = { + graduationUserId: number; + submissionId: number; +}; + +type UpdateGraduationApprovalResponse = { + id: number; +}; + +export const updateGraduationApproval = async ({ + graduationUserId, + submissionId, +}: UpdateGraduationApprovalParams) => { + const response = await patch({ + request: END_POINT.ADMIN.GRADUATION_USER_APPROVE( + graduationUserId, + submissionId, + ), + }); + + return response.data; +}; diff --git a/apps/graduate/src/admin/entities/graduation-approval/api/updateGraduationBatchApproval.ts b/apps/graduate/src/admin/entities/graduation-approval/api/updateGraduationBatchApproval.ts deleted file mode 100644 index 1e2b3c6a..00000000 --- a/apps/graduate/src/admin/entities/graduation-approval/api/updateGraduationBatchApproval.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { patch } from '~/shared/api'; -import { END_POINT } from '~/shared/constants'; - -type UpdateGraduationBatchApprovalRequest = { - ids: number[]; -}; - -type UpdateGraduationBatchApprovalResponse = { - approvedIds: number[]; -}; - -export const updateGraduationBatchApproval = async (ids: number[]) => { - const response = await patch< - UpdateGraduationBatchApprovalResponse, - UpdateGraduationBatchApprovalRequest - >({ - request: END_POINT.ADMIN.GRADUATION_USERS_BATCH_APPROVE, - data: { ids }, - }); - - return response.data; -}; diff --git a/apps/graduate/src/admin/entities/graduation-approval/api/updateGraduationDisapproval.ts b/apps/graduate/src/admin/entities/graduation-approval/api/updateGraduationDisapproval.ts new file mode 100644 index 00000000..72c04003 --- /dev/null +++ b/apps/graduate/src/admin/entities/graduation-approval/api/updateGraduationDisapproval.ts @@ -0,0 +1,25 @@ +import { patch } from '~/shared/api'; +import { END_POINT } from '~/shared/constants'; + +export type UpdateGraduationDisapprovalParams = { + graduationUserId: number; + submissionId: number; +}; + +type UpdateGraduationDisapprovalResponse = { + id: number; +}; + +export const updateGraduationDisapproval = async ({ + graduationUserId, + submissionId, +}: UpdateGraduationDisapprovalParams) => { + const response = await patch({ + request: END_POINT.ADMIN.GRADUATION_USER_DISAPPROVE( + graduationUserId, + submissionId, + ), + }); + + return response.data; +}; diff --git a/apps/graduate/src/admin/entities/graduation-approval/model/index.ts b/apps/graduate/src/admin/entities/graduation-approval/model/index.ts index 1668bce6..300094ca 100644 --- a/apps/graduate/src/admin/entities/graduation-approval/model/index.ts +++ b/apps/graduate/src/admin/entities/graduation-approval/model/index.ts @@ -1,2 +1,2 @@ -export * from './useGraduationApproval'; -export * from './useGraduationBatchApproval'; +export * from './useGraduationBatchApproval'; +export * from './useGraduationBatchDisapproval'; diff --git a/apps/graduate/src/admin/entities/graduation-approval/model/useGraduationBatchApproval.ts b/apps/graduate/src/admin/entities/graduation-approval/model/useGraduationBatchApproval.ts index 574b3d3c..e8904b6d 100644 --- a/apps/graduate/src/admin/entities/graduation-approval/model/useGraduationBatchApproval.ts +++ b/apps/graduate/src/admin/entities/graduation-approval/model/useGraduationBatchApproval.ts @@ -1,22 +1,54 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { graduationUsersKeys } from '~/shared/queries'; +import { graduationUsersKeys, studentKeys } from '~/shared/queries'; -import { updateGraduationBatchApproval } from '../api'; +import { + type UpdateGraduationApprovalParams, + updateGraduationApproval, +} from '../api'; type Options = { onSuccess?: () => void | Promise; }; +type UpdateGraduationBatchApprovalResult = { + approvedIds: number[]; + successCount: number; + failureCount: number; +}; + export function useGraduationBatchApproval({ onSuccess }: Options = {}) { const queryClient = useQueryClient(); - const mutation = useMutation({ - mutationFn: updateGraduationBatchApproval, - onSuccess: async () => { + const mutation = useMutation< + UpdateGraduationBatchApprovalResult, + Error, + UpdateGraduationApprovalParams[] + >({ + mutationFn: async targets => { + const settled = await Promise.allSettled( + targets.map(target => updateGraduationApproval(target)), + ); + const approvedIds = settled.flatMap((result, index) => + result.status === 'fulfilled' ? [targets[index].graduationUserId] : [], + ); + + return { + approvedIds, + successCount: approvedIds.length, + failureCount: targets.length - approvedIds.length, + }; + }, + onSuccess: async result => { + if (result.successCount === 0) { + return; + } await queryClient.invalidateQueries({ queryKey: graduationUsersKeys.all, }); + await queryClient.invalidateQueries({ + queryKey: studentKeys.details(), + }); await onSuccess?.(); }, }); diff --git a/apps/graduate/src/admin/entities/graduation-approval/model/useGraduationBatchDisapproval.ts b/apps/graduate/src/admin/entities/graduation-approval/model/useGraduationBatchDisapproval.ts new file mode 100644 index 00000000..f1e57581 --- /dev/null +++ b/apps/graduate/src/admin/entities/graduation-approval/model/useGraduationBatchDisapproval.ts @@ -0,0 +1,62 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { graduationUsersKeys, studentKeys } from '~/shared/queries'; + +import { + type UpdateGraduationDisapprovalParams, + updateGraduationDisapproval, +} from '../api'; + +type Options = { + onSuccess?: () => void | Promise; +}; + +type UpdateGraduationBatchDisapprovalResult = { + disapprovedIds: number[]; + successCount: number; + failureCount: number; +}; + +export function useGraduationBatchDisapproval({ onSuccess }: Options = {}) { + const queryClient = useQueryClient(); + + const mutation = useMutation< + UpdateGraduationBatchDisapprovalResult, + Error, + UpdateGraduationDisapprovalParams[] + >({ + mutationFn: async targets => { + const settled = await Promise.allSettled( + targets.map(target => updateGraduationDisapproval(target)), + ); + const disapprovedIds = settled.flatMap((result, index) => + result.status === 'fulfilled' + ? [targets[index].graduationUserId] + : [], + ); + + return { + disapprovedIds, + successCount: disapprovedIds.length, + failureCount: targets.length - disapprovedIds.length, + }; + }, + onSuccess: async result => { + if (result.successCount === 0) { + return; + } + await queryClient.invalidateQueries({ + queryKey: graduationUsersKeys.all, + }); + await queryClient.invalidateQueries({ + queryKey: studentKeys.details(), + }); + await onSuccess?.(); + }, + }); + + return { + disapproveGraduationUsers: mutation.mutateAsync, + mutation, + }; +} diff --git a/apps/graduate/src/admin/features/graduationApproval/hooks/index.ts b/apps/graduate/src/admin/features/graduationApproval/hooks/index.ts new file mode 100644 index 00000000..7ce0892a --- /dev/null +++ b/apps/graduate/src/admin/features/graduationApproval/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useGraduationApproval'; +export * from './useGraduationDisapproval'; diff --git a/apps/graduate/src/admin/entities/graduation-approval/model/useGraduationApproval.ts b/apps/graduate/src/admin/features/graduationApproval/hooks/useGraduationApproval.ts similarity index 75% rename from apps/graduate/src/admin/entities/graduation-approval/model/useGraduationApproval.ts rename to apps/graduate/src/admin/features/graduationApproval/hooks/useGraduationApproval.ts index e3de98aa..11413efa 100644 --- a/apps/graduate/src/admin/entities/graduation-approval/model/useGraduationApproval.ts +++ b/apps/graduate/src/admin/features/graduationApproval/hooks/useGraduationApproval.ts @@ -2,15 +2,15 @@ import { createElement } from 'react'; import { useToast } from '~/shared/hooks'; -import { useGraduationBatchApproval } from './useGraduationBatchApproval'; - +import { useGraduationBatchApproval } from '~/admin/entities/graduation-approval/model'; import { APPROVE_ALERT, - APPROVE_CONFIRM_TITLE, - APPROVE_OK_TEXT, APPROVE_CANCEL_TEXT, + APPROVE_CONFIRM_TITLE, APPROVE_EMPTY, + APPROVE_FAILED, APPROVE_NOTHING, + APPROVE_OK_TEXT, APPROVE_REASON_ALREADY_APPROVED, APPROVE_REASON_FAILED, APPROVE_REASON_NOT_SUBMITTED, @@ -19,13 +19,14 @@ import { APPROVE_RESULT_NOT_APPROVED, APPROVE_RESULT_TITLE, APPROVE_SUCCESS, -} from '~/admin/shared/ui/Toolbar/toolbarTexts'; +} from '~/admin/shared/constants/actionTexts'; type UseApproveGraduationUsersProps = { items: T[]; selectedIds: number[]; getId: (item: T) => number; getLabel: (item: T) => string; + getSubmissionId: (item: T) => number | null; status: { isSubmitted: (item: T) => boolean; isApproved: (item: T) => boolean; @@ -34,7 +35,6 @@ type UseApproveGraduationUsersProps = { }; type NotApprovedDetail = { - id: number; label: string; reason: string; }; @@ -44,6 +44,7 @@ export function useGraduationApproval({ selectedIds, getId, getLabel, + getSubmissionId, status, onSuccess, }: UseApproveGraduationUsersProps) { @@ -81,17 +82,14 @@ export function useGraduationApproval({ 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, })), @@ -107,6 +105,7 @@ export function useGraduationApproval({ const notSubmitted: T[] = []; const pending: T[] = []; const alreadyApproved: T[] = []; + const invalidTargets: T[] = []; selected.forEach(item => { const submitted = status.isSubmitted(item); @@ -120,15 +119,24 @@ export function useGraduationApproval({ alreadyApproved.push(item); return; } + if (getSubmissionId(item) == null) { + invalidTargets.push(item); + return; + } pending.push(item); }); if (pending.length === 0) { info({ title: APPROVE_RESULT_TITLE, + centered: true, content: buildResultContent( [], - buildNotApprovedDetails(notSubmitted, alreadyApproved), + buildNotApprovedDetails( + notSubmitted, + alreadyApproved, + invalidTargets, + ), ), }); return; @@ -139,11 +147,22 @@ export function useGraduationApproval({ content: APPROVE_ALERT, okText: APPROVE_OK_TEXT, cancelText: APPROVE_CANCEL_TEXT, + centered: true, onOk: async () => { try { - const result = await approveGraduationUsers( - pending.map(item => getId(item)), - ); + const approvalTargets = pending.flatMap(item => { + const submissionId = getSubmissionId(item); + return submissionId == null + ? [] + : [ + { + graduationUserId: getId(item), + submissionId, + }, + ]; + }); + + const result = await approveGraduationUsers(approvalTargets); const approvedIdSet = new Set(result.approvedIds); const approved = pending.filter(item => approvedIdSet.has(getId(item)), @@ -154,19 +173,27 @@ export function useGraduationApproval({ info({ title: APPROVE_RESULT_TITLE, + centered: true, content: buildResultContent( approved, - buildNotApprovedDetails(notSubmitted, alreadyApproved, failed), + buildNotApprovedDetails( + notSubmitted, + alreadyApproved, + [...invalidTargets, ...failed], + ), ), }); - if (approved.length > 0) { + if (result.successCount > 0) { toast.success(APPROVE_SUCCESS); + } else if (result.failureCount > 0) { + toast.error(APPROVE_FAILED); } else { toast.warning(APPROVE_NOTHING); } } catch { /* 실패 시 MutationCache 전역 토스트로 안내 */ + return; } }, }); diff --git a/apps/graduate/src/admin/features/graduationApproval/hooks/useGraduationDisapproval.ts b/apps/graduate/src/admin/features/graduationApproval/hooks/useGraduationDisapproval.ts new file mode 100644 index 00000000..51b5af21 --- /dev/null +++ b/apps/graduate/src/admin/features/graduationApproval/hooks/useGraduationDisapproval.ts @@ -0,0 +1,203 @@ +import { createElement } from 'react'; + +import { useToast } from '~/shared/hooks'; + +import { useGraduationBatchDisapproval } from '~/admin/entities/graduation-approval/model'; +import { + DISAPPROVE_ALERT, + DISAPPROVE_CANCEL_TEXT, + DISAPPROVE_CONFIRM_TITLE, + DISAPPROVE_EMPTY, + DISAPPROVE_FAILED, + DISAPPROVE_NOTHING, + DISAPPROVE_OK_TEXT, + DISAPPROVE_REASON_FAILED, + DISAPPROVE_REASON_NOT_APPROVED, + DISAPPROVE_REASON_NOT_SUBMITTED, + DISAPPROVE_RESULT_DISAPPROVED, + DISAPPROVE_RESULT_NONE, + DISAPPROVE_RESULT_NOT_DISAPPROVED, + DISAPPROVE_RESULT_TITLE, + DISAPPROVE_SUCCESS, +} from '~/admin/shared/constants/actionTexts'; + +type UseDisapproveGraduationUsersProps = { + items: T[]; + selectedIds: number[]; + getId: (item: T) => number; + getLabel: (item: T) => string; + getSubmissionId: (item: T) => number | null; + status: { + isSubmitted: (item: T) => boolean; + isApproved: (item: T) => boolean; + }; + onSuccess?: () => void | Promise; +}; + +type NotDisapprovedDetail = { + label: string; + reason: string; +}; + +export function useGraduationDisapproval({ + items, + selectedIds, + getId, + getLabel, + getSubmissionId, + status, + onSuccess, +}: UseDisapproveGraduationUsersProps) { + const { toast, confirm, info } = useToast(); + const { disapproveGraduationUsers } = useGraduationBatchDisapproval({ + onSuccess, + }); + + const buildResultContent = ( + disapprovedUsers: T[], + notDisapprovedDetails: NotDisapprovedDetail[], + ) => { + const disapprovedLines = + disapprovedUsers.length > 0 + ? disapprovedUsers.map(user => `- ${getLabel(user)}`) + : [DISAPPROVE_RESULT_NONE]; + const notDisapprovedLines = + notDisapprovedDetails.length > 0 + ? notDisapprovedDetails.map(item => `- ${item.label} (${item.reason})`) + : [DISAPPROVE_RESULT_NONE]; + + return [ + DISAPPROVE_RESULT_DISAPPROVED, + ...disapprovedLines, + DISAPPROVE_RESULT_NOT_DISAPPROVED, + ...notDisapprovedLines, + ] + .filter(Boolean) + .map((line, index) => createElement('div', { key: index }, line)); + }; + + const buildNotDisapprovedDetails = ( + notSubmitted: T[], + notApproved: T[], + failed: T[] = [], + ) => [ + ...notSubmitted.map(user => ({ + label: getLabel(user), + reason: DISAPPROVE_REASON_NOT_SUBMITTED, + })), + ...notApproved.map(user => ({ + label: getLabel(user), + reason: DISAPPROVE_REASON_NOT_APPROVED, + })), + ...failed.map(user => ({ + label: getLabel(user), + reason: DISAPPROVE_REASON_FAILED, + })), + ]; + + const handleDisapproveSelected = () => { + if (selectedIds.length === 0) { + toast.warning(DISAPPROVE_EMPTY); + return; + } + + const selected = items.filter(item => selectedIds.includes(getId(item))); + const notSubmitted: T[] = []; + const notApproved: T[] = []; + const pending: T[] = []; + const invalidTargets: T[] = []; + + selected.forEach(item => { + const submitted = status.isSubmitted(item); + const approved = status.isApproved(item); + + if (!submitted) { + notSubmitted.push(item); + return; + } + if (!approved) { + notApproved.push(item); + return; + } + if (getSubmissionId(item) == null) { + invalidTargets.push(item); + return; + } + + pending.push(item); + }); + + if (pending.length === 0) { + info({ + title: DISAPPROVE_RESULT_TITLE, + centered: true, + content: buildResultContent( + [], + buildNotDisapprovedDetails( + notSubmitted, + notApproved, + invalidTargets, + ), + ), + }); + return; + } + + confirm({ + title: DISAPPROVE_CONFIRM_TITLE, + content: DISAPPROVE_ALERT, + okText: DISAPPROVE_OK_TEXT, + cancelText: DISAPPROVE_CANCEL_TEXT, + centered: true, + onOk: async () => { + try { + const disapprovalTargets = pending.flatMap(item => { + const submissionId = getSubmissionId(item); + return submissionId == null + ? [] + : [ + { + graduationUserId: getId(item), + submissionId, + }, + ]; + }); + + const result = await disapproveGraduationUsers(disapprovalTargets); + const disapprovedIdSet = new Set(result.disapprovedIds); + const disapproved = pending.filter(item => + disapprovedIdSet.has(getId(item)), + ); + const failed = pending.filter( + item => !disapprovedIdSet.has(getId(item)), + ); + + info({ + title: DISAPPROVE_RESULT_TITLE, + centered: true, + content: buildResultContent( + disapproved, + buildNotDisapprovedDetails( + notSubmitted, + notApproved, + [...invalidTargets, ...failed], + ), + ), + }); + + if (result.successCount > 0) { + toast.success(DISAPPROVE_SUCCESS); + } else if (result.failureCount > 0) { + toast.error(DISAPPROVE_FAILED); + } else { + toast.warning(DISAPPROVE_NOTHING); + } + } catch { + /* 실패 시 MutationCache 전역 토스트로 안내 */ + } + }, + }); + }; + + return { handleDisapproveSelected }; +} diff --git a/apps/graduate/src/admin/features/graduationApproval/index.ts b/apps/graduate/src/admin/features/graduationApproval/index.ts new file mode 100644 index 00000000..03a9012c --- /dev/null +++ b/apps/graduate/src/admin/features/graduationApproval/index.ts @@ -0,0 +1 @@ +export * from './hooks'; diff --git a/apps/graduate/src/admin/pages/all/constants/allManagementColumns.tsx b/apps/graduate/src/admin/pages/all/constants/allManagementColumns.tsx index 0b4d66b9..ca118af7 100644 --- a/apps/graduate/src/admin/pages/all/constants/allManagementColumns.tsx +++ b/apps/graduate/src/admin/pages/all/constants/allManagementColumns.tsx @@ -7,52 +7,37 @@ import { } from './allManagementTexts'; import type { AllManagementRow } from '../types/allManagement'; +import { NameCellButton } from '~/admin/shared/ui'; import type { Column } from '~/admin/shared/ui/DataTable/DataTable'; -import { vars } from '~/vars.css'; export const allManagementColumns = ( onNameClick: (id: number) => void, -): ReadonlyArray> => - [ - { key: 'no', header: HEADER_NO, width: 56, cell: r => r.no }, - { - key: 'studentId', - header: HEADER_STUDENT_ID, - width: 100, - cell: r => r.studentId, - }, - { - key: 'name', - header: HEADER_NAME, - width: 120, - cell: r => ( - - ), - }, - { - key: 'type', - header: HEADER_TYPE, - width: 120, - cell: r => r.graduationTypeLabel, - }, - { - key: 'status', - header: HEADER_STATUS, - width: 140, - cell: r => r.statusText, - }, - ] as const; +): Column[] => [ + { key: 'no', header: HEADER_NO, width: 56, cell: r => r.no }, + { + key: 'studentId', + header: HEADER_STUDENT_ID, + width: 100, + cell: r => r.studentId, + }, + { + key: 'name', + header: HEADER_NAME, + width: 120, + cell: r => ( + onNameClick(r.id)} /> + ), + }, + { + key: 'type', + header: HEADER_TYPE, + width: 120, + cell: r => r.graduationTypeLabel, + }, + { + key: 'status', + header: HEADER_STATUS, + width: 140, + cell: r => r.statusText, + }, +]; diff --git a/apps/graduate/src/admin/pages/all/model/useAllManagement.ts b/apps/graduate/src/admin/pages/all/model/useAllManagement.ts index 39861e33..28cce230 100644 --- a/apps/graduate/src/admin/pages/all/model/useAllManagement.ts +++ b/apps/graduate/src/admin/pages/all/model/useAllManagement.ts @@ -1,12 +1,11 @@ -import { useNavigate, useSearch } from '@tanstack/react-router'; -import { useState, useEffect } from 'react'; +import { useNavigate, useSearch } from '@tanstack/react-router'; +import { useEffect, useState } from 'react'; import { useToast } from '~/shared/hooks'; import { allManagementColumns } from '../constants/allManagementColumns'; import { TYPE_LABEL, TYPE_UNKNOWN } from '../constants/allManagementTexts'; -import { AllManagementRow } from '../types/allManagement'; -import { getStatusLabel } from '../utils'; +import type { AllManagementRow } from '../types/allManagement'; import { useScheduleList } from '~/admin/entities/admin-schedule/model'; import { useFetchGraduationUsers } from '~/admin/entities/graduation-users/model/useFetchGraduationUsers'; @@ -14,12 +13,13 @@ import { useRemoveGraduationUsers } from '~/admin/entities/graduation-users/mode import { DELETE_ALERT, DELETE_CONFIRM_TITLE, -} from '~/admin/shared/ui/Toolbar/toolbarTexts'; +} from '~/admin/shared/constants/actionTexts'; +import { getStatusLabel } from '~/admin/shared/utils'; import { useAdminDownload, useAdminPagination, useAdminSelection, -} from '~/admin/widgets/Table/model'; +} from '~/admin/widgets/GraduationManagementTable/model'; export const useAllManagement = () => { const navigate = useNavigate(); @@ -101,7 +101,7 @@ export const useAllManagement = () => { await removeGraduationUsers(selectedIds); toast.success('선택한 학생을 삭제했습니다.'); } catch { - /* 실패 시 MutationCache 전역 토스트 */ + /* 실패 안내는 MutationCache 전역 토스트 사용 */ } }, }); diff --git a/apps/graduate/src/admin/pages/all/types/allManagement.ts b/apps/graduate/src/admin/pages/all/types/allManagement.ts index 1c0a00b2..b2b0072e 100644 --- a/apps/graduate/src/admin/pages/all/types/allManagement.ts +++ b/apps/graduate/src/admin/pages/all/types/allManagement.ts @@ -4,7 +4,14 @@ import type { ThesisSubmission, } from '~/shared/types/graduation'; -export type { CertificateSubmission as CertificateStatus, SubmissionStatus as StudentStatus, ThesisSubmission as ThesisStatus }; +import type { PeriodData, StageData } from '~/admin/shared/types/studentDetail'; + +export type { + CertificateSubmission as CertificateStatus, + SubmissionStatus as StudentStatus, + ThesisSubmission as ThesisStatus, +}; +export type { PeriodData, StageData }; export type AllManagementRow = { id: number; @@ -15,19 +22,3 @@ export type AllManagementRow = { graduationTypeLabel: string; statusText: string; }; - -export type PeriodData = { - certificate?: string; - midThesis?: string; - finalThesis?: string; -}; - -export interface StageData { - key: string; - stage: string; - period: string; - createdAt: string | null; - isSubmitted: boolean; - isApproved: boolean; - fileId: number | null; -} diff --git a/apps/graduate/src/admin/pages/all/ui/AllManagementPage.tsx b/apps/graduate/src/admin/pages/all/ui/AllManagementPage.tsx index aa7d13ba..53640384 100644 --- a/apps/graduate/src/admin/pages/all/ui/AllManagementPage.tsx +++ b/apps/graduate/src/admin/pages/all/ui/AllManagementPage.tsx @@ -1,18 +1,22 @@ -import { Header, Pagination } from '~/shared/ui/index.ts'; +import { useState } from 'react'; + +import { Header, Pagination } from '~/shared/ui'; import { LOADING_TEXT, TITLE_ALL_MANAGEMENT, } from '../constants/allManagementTexts'; +import { useAllManagement } from '../model/useAllManagement'; import type { AllManagementRow } from '../types/allManagement'; -import { extractPeriodData } from '../utils'; -import UserDetailModal from './UserDetailModal.tsx'; -import { useAllManagement } from '../model/useAllManagement.ts'; import * as style from '~/admin/shared/styles/adminPage.css'; -import { DataTable, Toolbar } from '~/admin/shared/ui/index.ts'; +import { DataTable, Toolbar, UserDetailModal } from '~/admin/shared/ui'; +import { extractPeriodData } from '~/admin/shared/utils'; +import { StudentAddModal } from '~/admin/widgets/StudentAddModal'; export default function AllManagementPage() { + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const { page, pageSize, @@ -44,10 +48,9 @@ export default function AllManagementPage() { selectedCount={selectedIds.length} query={query} onQueryChange={handleQueryChange} - onApprove={() => {}} onDeleteSelected={handleDeleteSelected} onDownload={handleDownload} - disabledApprove={true} + onAddStudent={() => setIsAddModalOpen(true)} />
@@ -69,6 +72,10 @@ export default function AllManagementPage() { period={extractPeriodData(schedules)} /> )} + setIsAddModalOpen(false)} + /> > = [ +export const certColumns = ( + onNameClick: (id: number) => void, +): Column[] => [ { key: 'no', header: '번호', width: 56, cell: r => r.no }, { key: 'studentId', header: '학번', width: 120, cell: r => r.studentId }, - { key: 'name', header: '이름', width: 160, cell: r => r.name }, + { + key: 'name', + header: HEADER_NAME, + width: 160, + cell: r => ( + onNameClick(r.id)} /> + ), + }, { key: 'status', header: '상태', width: 120, cell: r => r.status }, { key: 'approved', header: '승인 여부', width: 120, cell: r => r.approved }, -] as const; +]; diff --git a/apps/graduate/src/admin/pages/certification/types/row.ts b/apps/graduate/src/admin/pages/certification/types/row.ts index a85822b1..041773bc 100644 --- a/apps/graduate/src/admin/pages/certification/types/row.ts +++ b/apps/graduate/src/admin/pages/certification/types/row.ts @@ -3,6 +3,6 @@ export type CertRow = { no: number; studentId: string; name: string; - status: '제출' | '미제출'; - approved: '승인' | '미승인'; + status: string; + approved: string; }; diff --git a/apps/graduate/src/admin/pages/certification/ui/CertificationAdminPage.tsx b/apps/graduate/src/admin/pages/certification/ui/CertificationAdminPage.tsx index f2dcb521..9edd7206 100644 --- a/apps/graduate/src/admin/pages/certification/ui/CertificationAdminPage.tsx +++ b/apps/graduate/src/admin/pages/certification/ui/CertificationAdminPage.tsx @@ -1,8 +1,8 @@ -import { Header, Pagination } from '~/shared/ui'; +import { Header, Pagination } from '~/shared/ui'; import * as style from '~/admin/shared/styles/adminPage.css'; -import { Table } from '~/admin/widgets/Table'; -import { useAdminPagination } from '~/admin/widgets/Table/model'; +import { GraduationManagementTable } from '~/admin/widgets/GraduationManagementTable'; +import { useAdminPagination } from '~/admin/widgets/GraduationManagementTable/model'; export default function CertificationAdminPage() { const { @@ -18,7 +18,7 @@ export default function CertificationAdminPage() {
- { - toast.success('승인이 완료되었습니다.'); + toast.success(APPROVE_SUCCESS); await queryClient.invalidateQueries({ queryKey: [...studentKeys.files()], }); @@ -49,7 +53,7 @@ export default function FilePreviewPage() { }); const clearHideTimeout = () => { - if (hideTimeoutRef.current) { + if (hideTimeoutRef.current !== null) { clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } @@ -95,21 +99,25 @@ export default function FilePreviewPage() { } const { scheduleId, file, approval } = fileData; - const { name, studentId, status } = studentDetail; const handleGoBack = () => { navigate({ to: ROUTE.ALL, - search: { graduationUserId: graduationUserId }, + search: { graduationUserId }, }); }; const handleApprove = async () => { - try { - await approveGraduationUsers([graduationUserId]); - } catch { - /* 실패 시 MutationCache 전역 토스트 */ + const result = await approveGraduationUsers([ + { + graduationUserId, + submissionId: fileId, + }, + ]); + + if (result.successCount === 0) { + toast.error(APPROVE_FAILED); } }; @@ -126,15 +134,18 @@ export default function FilePreviewPage() { to: ROUTE.FILE_PREVIEW, search: { fileId: status.finalThesis.id!, - graduationUserId: graduationUserId, + graduationUserId, }, }); - } else if (scheduleId === getSubmissionTypeIndex('FINALTHESIS')) { + return; + } + + if (scheduleId === getSubmissionTypeIndex('FINALTHESIS')) { navigate({ to: ROUTE.FILE_PREVIEW, search: { fileId: status.midThesis.id!, - graduationUserId: graduationUserId, + graduationUserId, }, }); } diff --git a/apps/graduate/src/admin/pages/schedule/constants/schedule.ts b/apps/graduate/src/admin/pages/schedule/constants/schedule.ts index ac720912..ac121242 100644 --- a/apps/graduate/src/admin/pages/schedule/constants/schedule.ts +++ b/apps/graduate/src/admin/pages/schedule/constants/schedule.ts @@ -1,30 +1,6 @@ -import type { SubmissionType, SubmissionTypeLabel } from '~/shared/types'; - -export const SUBMISSION_TYPE_OPTIONS = [ - { value: 'SUBMITTED', label: '신청접수' }, - { value: 'MIDTHESIS', label: '중간보고서' }, - { value: 'FINALTHESIS', label: '최종보고서' }, - { value: 'CERTIFICATE', label: '자격증' }, - { value: 'APPROVED', label: '최종 통과' }, - { value: 'OTHER', label: '기타자격' }, -] as const; - -export function getSubmissionTypeByIndex( - index: number, -): SubmissionType | undefined { - return SUBMISSION_TYPE_OPTIONS[index - 1]?.value; -} - -export function getSubmissionTypeIndex(type: SubmissionType): number { - const index = SUBMISSION_TYPE_OPTIONS.findIndex( - option => option.value === type, - ); - return index !== -1 ? index + 1 : 0; -} - -export function getSubmissionTypeLabel( - type: SubmissionType, -): SubmissionTypeLabel { - const option = SUBMISSION_TYPE_OPTIONS.find(option => option.value === type); - return option?.label ?? '기타자격'; -} +export { + getSubmissionTypeByIndex, + getSubmissionTypeIndex, + getSubmissionTypeLabel, + SUBMISSION_TYPE_OPTIONS, +} from '~/shared/constants'; \ No newline at end of file diff --git a/apps/graduate/src/admin/pages/thesis/constants/thesisColumns.ts b/apps/graduate/src/admin/pages/thesis/constants/thesisColumns.tsx similarity index 63% rename from apps/graduate/src/admin/pages/thesis/constants/thesisColumns.ts rename to apps/graduate/src/admin/pages/thesis/constants/thesisColumns.tsx index 5dbd5247..d4694310 100644 --- a/apps/graduate/src/admin/pages/thesis/constants/thesisColumns.ts +++ b/apps/graduate/src/admin/pages/thesis/constants/thesisColumns.tsx @@ -1,11 +1,22 @@ import type { ThesisRow } from '../types/row'; +import { HEADER_NAME } from '~/admin/pages/all/constants/allManagementTexts'; +import { NameCellButton } from '~/admin/shared/ui'; import type { Column } from '~/admin/shared/ui/DataTable/DataTable'; -export const thesisColumns: ReadonlyArray> = [ +export const thesisColumns = ( + onNameClick: (id: number) => void, +): Column[] => [ { key: 'no', header: '번호', width: 30, cell: r => r.no }, { key: 'studentId', header: '학번', width: 90, cell: r => r.studentId }, - { key: 'name', header: '이름', width: 50, cell: r => r.name }, + { + key: 'name', + header: HEADER_NAME, + width: 50, + cell: r => ( + onNameClick(r.id)} /> + ), + }, { key: 'advisor', header: '지도교수', width: 50, cell: r => r.advisor }, { key: 'gradTerm', header: '졸업 년도', width: 90, cell: r => r.gradTerm }, { key: 'status', header: '상태', width: 130, cell: r => r.status }, @@ -16,4 +27,4 @@ export const thesisColumns: ReadonlyArray> = [ cell: r => r.submissionStatus, }, { key: 'approved', header: '승인 여부', width: 70, cell: r => r.approved }, -] as const; +]; diff --git a/apps/graduate/src/admin/pages/thesis/types/row.ts b/apps/graduate/src/admin/pages/thesis/types/row.ts index 30728f9b..770dcc30 100644 --- a/apps/graduate/src/admin/pages/thesis/types/row.ts +++ b/apps/graduate/src/admin/pages/thesis/types/row.ts @@ -6,6 +6,6 @@ export type ThesisRow = { advisor: string; gradTerm: string; status: string; - submissionStatus: '제출' | '미제출'; - approved: '승인' | '미승인'; + submissionStatus: string; + approved: string; }; diff --git a/apps/graduate/src/admin/pages/thesis/ui/ThesisAdminPage.tsx b/apps/graduate/src/admin/pages/thesis/ui/ThesisAdminPage.tsx index ce22e2d7..0e4a3050 100644 --- a/apps/graduate/src/admin/pages/thesis/ui/ThesisAdminPage.tsx +++ b/apps/graduate/src/admin/pages/thesis/ui/ThesisAdminPage.tsx @@ -1,8 +1,8 @@ -import { Header, Pagination } from '~/shared/ui'; +import { Header, Pagination } from '~/shared/ui'; import * as style from '~/admin/shared/styles/adminPage.css'; -import { Table } from '~/admin/widgets/Table'; -import { useAdminPagination } from '~/admin/widgets/Table/model'; +import { GraduationManagementTable } from '~/admin/widgets/GraduationManagementTable'; +import { useAdminPagination } from '~/admin/widgets/GraduationManagementTable/model'; export default function ThesisAdminPage() { const { @@ -18,7 +18,7 @@ export default function ThesisAdminPage() {
-
= { type Id = string | number; type Props = { - rows: ReadonlyArray; - columns: ReadonlyArray>; + rows: T[]; + columns: Column[]; getRowId: (row: T) => Id; allChecked?: boolean; onToggleAll?: () => void; - selectedIds?: ReadonlyArray; + selectedIds?: Id[]; onToggleOne?: (id: Id) => void; emptyText?: React.ReactNode; rowClassName?: (row: T, isSelected: boolean) => string | undefined; diff --git a/apps/graduate/src/admin/shared/ui/NameCellButton/NameCellButton.tsx b/apps/graduate/src/admin/shared/ui/NameCellButton/NameCellButton.tsx new file mode 100644 index 00000000..17505eba --- /dev/null +++ b/apps/graduate/src/admin/shared/ui/NameCellButton/NameCellButton.tsx @@ -0,0 +1,27 @@ +import { vars } from '~/vars.css'; + +interface NameCellButtonProps { + name: string; + onClick: () => void; +} + +export default function NameCellButton({ name, onClick }: NameCellButtonProps) { + return ( + + ); +} diff --git a/apps/graduate/src/admin/shared/ui/NameCellButton/index.ts b/apps/graduate/src/admin/shared/ui/NameCellButton/index.ts new file mode 100644 index 00000000..db36757a --- /dev/null +++ b/apps/graduate/src/admin/shared/ui/NameCellButton/index.ts @@ -0,0 +1 @@ +export { default as NameCellButton } from './NameCellButton'; diff --git a/apps/graduate/src/admin/shared/ui/Toolbar/Toolbar.tsx b/apps/graduate/src/admin/shared/ui/Toolbar/Toolbar.tsx index 6dab0feb..6418af25 100644 --- a/apps/graduate/src/admin/shared/ui/Toolbar/Toolbar.tsx +++ b/apps/graduate/src/admin/shared/ui/Toolbar/Toolbar.tsx @@ -1,19 +1,18 @@ -import { Button } from 'antd'; -import { useState } from 'react'; +import { Button } from 'antd'; import * as style from './Toolbar.css'; -import { StudentAddModal } from '~/admin/widgets/StudentAddModal'; - +import { DISAPPROVE_BUTTON_TEXT } from '~/admin/shared/constants/actionTexts'; type Props = { selectedCount: number; query: string; onQueryChange: (v: string) => void; - onApprove: () => void; + onApprove?: () => void; + onDisapprove?: () => void; onDeleteSelected?: () => void; onDownload?: () => void; - disabledApprove?: boolean; + onAddStudent?: () => void; }; export default function Toolbar({ @@ -21,12 +20,12 @@ export default function Toolbar({ query, onQueryChange, onApprove, + onDisapprove, onDeleteSelected, onDownload, - disabledApprove = false, + onAddStudent, }: Props) { const hasSelection = selectedCount > 0; - const [addOpen, setAddOpen] = useState(false); return (
@@ -48,11 +47,16 @@ export default function Toolbar({
- {!disabledApprove && ( + {onApprove && ( )} + {onDisapprove && ( + + )} {onDeleteSelected && ( )} - + {onAddStudent && ( + + )}
@@ -95,8 +89,7 @@ export default function Toolbar({ 검색
- - setAddOpen(false)} />
); } + diff --git a/apps/graduate/src/admin/shared/ui/Toolbar/toolbarTexts.ts b/apps/graduate/src/admin/shared/ui/Toolbar/toolbarTexts.ts deleted file mode 100644 index 70d301a2..00000000 --- a/apps/graduate/src/admin/shared/ui/Toolbar/toolbarTexts.ts +++ /dev/null @@ -1,17 +0,0 @@ -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/admin/pages/all/ui/UserDetailModal.tsx b/apps/graduate/src/admin/shared/ui/UserDetailModal/UserDetailModal.tsx similarity index 96% rename from apps/graduate/src/admin/pages/all/ui/UserDetailModal.tsx rename to apps/graduate/src/admin/shared/ui/UserDetailModal/UserDetailModal.tsx index 461bb846..6b20b3df 100644 --- a/apps/graduate/src/admin/pages/all/ui/UserDetailModal.tsx +++ b/apps/graduate/src/admin/shared/ui/UserDetailModal/UserDetailModal.tsx @@ -3,15 +3,14 @@ import { Descriptions, Modal, Spin, Table } from 'antd'; import { Header } from '~/shared/ui'; -import type { PeriodData, StageData } from '../types/allManagement'; +import { useStudentDetail } from '~/admin/features/studentDetail'; +import type { PeriodData, StageData } from '~/admin/shared/types/studentDetail'; +import { Container } from '~/admin/shared/ui/Container'; import { buildStageData, formatSubmissionStatus, getStatusLabel, -} from '../utils'; - -import { useStudentDetail } from '~/admin/features/studentDetail'; -import { Container } from '~/admin/shared/ui/Container'; +} from '~/admin/shared/utils'; import { vars } from '~/vars.css'; interface UserDetailModalProps { @@ -54,6 +53,7 @@ export default function UserDetailModal({ return record.isSubmitted ? (