diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..f3625743 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,83 @@ +name: Docker Image CI + +on: + push: + branches: ["main"] +env: + dockerimage_tag: ${{ github.sha }} + dockerimage_name: harbor.k8s.scg.skku.ac.kr/library/ice-gs-thesis-fe +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + steps: + - name: --------------- Code Repo --------------- + run: echo "Code Repo" + - name: Code Repo 불러오기 + uses: actions/checkout@v4 + - name: Docker 준비(1/4) - 메타데이터 생성 + id: meta + uses: docker/metadata-action@v5.5.1 + with: + images: | + ${{ env.dockerimage_name }} + tags: | + ${{ env.dockerimage_tag }} + latest + flavor: | + latest=true + - name: Docker 준비(2/4) - QEMU 설정 + uses: docker/setup-qemu-action@v3 + - name: Docker 준비(3/4) - buildx 설정 + uses: docker/setup-buildx-action@v3 + - name: Docker 준비(4/4) - 레지스트리 로그인 + uses: docker/login-action@v2 + with: + registry: ${{ secrets.HARBOR_REGISTRY }} + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + - name: env 파일 생성 + run: | + echo NEXT_PUBLIC_API_ENDPOINT=${{secrets.API_ENDPOINT_PROD}} >> .env + cat .env + - name: Docker 이미지 빌드+푸시 + id: build-and-push + uses: docker/build-push-action@v5.1.0 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + - name: --------------- Config Repo --------------- + run: echo "[Config Repo]" + - name: Config Repo 불러오기 + uses: actions/checkout@v4 + with: + repository: SystemConsultantGroup/ice-grad-thesis-config + ref: main + token: ${{ secrets.ACTION_TOKEN }} + path: ice-grad-thesis-config + - name: Kustomize 준비 + uses: imranismail/setup-kustomize@v2.0.0 + - name: Config Repo 이미지 값 업데이트 (Kustomize) + run: | + cd ice-grad-thesis-config/overlays/prod/fe/ + kustomize edit set image ${{ env.dockerimage_name }}:${{ env.dockerimage_tag }} + cat kustomization.yaml + - name: Config Repo 변경사항 푸시 + run: | + cd ice-grad-thesis-config + git config --global user.email "wefwef12e@naver.com" + git config --global user.name "hynseok" + git commit -am "Update image tag" + git push -u origin main + - name: --------------- Clean Up --------------- + run: echo "Clean Up" diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 10a05644..6754077c 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -42,7 +42,6 @@ jobs: - name: env 파일 생성 run: | echo NEXT_PUBLIC_API_ENDPOINT=${{secrets.API_ENDPOINT}} >> .env - echo NEXT_PUBLIC_REVIEW_API_ENDPOINT=${{secrets.REVIEW_API_ENDPOINT}} >> .env cat .env - name: Docker 이미지 빌드+푸시 id: build-and-push diff --git a/src/api/_types/reviews.ts b/src/api/_types/reviews.ts index 09c49b51..52f43ef8 100644 --- a/src/api/_types/reviews.ts +++ b/src/api/_types/reviews.ts @@ -76,7 +76,7 @@ export interface FinalReviewResponse extends CommonApiResponse { export interface UpdateReviewRequestBody { contentStatus: Status; presentationStatus?: Status | null; - comment: string; + comment?: string; fileUUID?: string; } diff --git a/src/api/apiRoute.ts b/src/api/apiRoute.ts index d426145c..ee30445c 100644 --- a/src/api/apiRoute.ts +++ b/src/api/apiRoute.ts @@ -56,6 +56,7 @@ export const API_ROUTES = { `/students/${studentId}/headReviewer/${reviewerId}`, // PUT: 학생 심사위원장 배정 deleteReviewer: (studentId: ApiId, reviewerId: ApiId) => `/students/${studentId}/reviewers/${reviewerId}`, // PUT: 학생 심사위원 배정 취소 + delete: () => "/students", // DELETE: 학생 삭제 }, review: { // 학생 본인의 논문 조회 @@ -90,7 +91,8 @@ export const API_ROUTES = { }, }, thesis: { - put: (thesisId: ApiId) => `/thesis/${thesisId}`, + // 백엔드 api 경로 오타로 theses로 설정 + put: (thesisId: ApiId) => `/theses/${thesisId}`, }, achievement: { get: (achievementId?: ApiId) => `/achievements/${achievementId ?? ""}`, diff --git a/src/app/admin/results/[thesisId]/AdminReviewContent.tsx b/src/app/admin/results/[thesisId]/AdminReviewContent.tsx index 3a27fd63..093e3171 100644 --- a/src/app/admin/results/[thesisId]/AdminReviewContent.tsx +++ b/src/app/admin/results/[thesisId]/AdminReviewContent.tsx @@ -1,5 +1,7 @@ "use client"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; import { ClientAxios } from "@/api/ClientAxios"; import { Status } from "@/api/_types/common"; import { AdminReviewResponse, ThesisReview, UpdateReviewRequestBody } from "@/api/_types/reviews"; @@ -8,11 +10,19 @@ import { API_ROUTES } from "@/api/apiRoute"; import { ApiDownloadButton } from "@/components/common/Buttons"; import { showNotificationSuccess } from "@/components/common/Notifications"; import SectionTitle from "@/components/common/SectionTitle"; -import { BasicRow, ButtonRow, RowGroup, TitleRow } from "@/components/common/rows"; +import { + BasicRow, + ButtonRow, + CommentTypeRow, + FileUploadRow, + RowGroup, + TextAreaRow, + TitleRow, +} from "@/components/common/rows"; import { AdminReviewList } from "@/components/pages/review/Review/ReviewList"; import { StatusButtons } from "@/components/pages/review/Review/StatusButtons"; import { Badge, Button, Modal, Space, Stack } from "@mantine/core"; -import { useState } from "react"; +import { uploadFile } from "@/api/_utils/uploadFile"; export function AdminReviewListContent({ data }: { data: AdminReviewResponse }) { const [open, setOpen] = useState(false); @@ -33,7 +43,7 @@ export function AdminReviewListContent({ data }: { data: AdminReviewResponse }) opened={!!open} onClose={() => setOpen(false)} centered - size="lg" + size="xl" padding="lg" radius="lg" withCloseButton={false} @@ -107,7 +117,7 @@ export function ReviewReportAdminEditable({ opened={!!open} onClose={() => setOpen(false)} centered - size="lg" + size="xl" padding="lg" radius="lg" withCloseButton={false} @@ -129,6 +139,15 @@ function ModalContent({ open, setOpen, data, current }: ModalProps) { const [loading, setLoading] = useState(false); const [thesis, setThesis] = useState(current.contentStatus); const [presentation, setPresentation] = useState(current.presentationStatus); + const [comment, setComment] = useState(current.comment); + const [reviewFile, setReviewFile] = useState(); + const [commentType, setCommentType] = useState(); + + const handleCommentChange = (event: React.ChangeEvent) => { + setComment(event.currentTarget.value); + }; + + const router = useRouter(); return (
setLoading(false)); + let fileUUID; + if (reviewFile) { + fileUUID = (await uploadFile(reviewFile)).uuid; + } else if (current.file) { + fileUUID = current.file.uuid ?? undefined; + } + await ClientAxios.put( current.isFinal ? API_ROUTES.review.final.put(current.id) - : API_ROUTES.review.put(current.id), + : data.stage === "REVISION" + ? API_ROUTES.review.revision.put(current.id) + : API_ROUTES.review.put(current.id), { - comment: current.comment, + ...(commentType === "심사 의견" ? { comment } : {}), contentStatus: thesis, - presentationStatus: presentation, + ...(current.isFinal ? {} : { presentationStatus: presentation }), + ...(commentType === "심사 의견 파일" ? { fileUUID } : {}), } satisfies UpdateReviewRequestBody, { baseURL: process.env.NEXT_PUBLIC_REVIEW_API_ENDPOINT } ); showNotificationSuccess({ message: current.isFinal - ? "최종심사 결과를 수정했습니다." - : `${current.reviewer.name} 교수의 심사를 수정했습니다.`, + ? "최종심사 결과를 수정했습니다." // NOTE: 심사위원장은 수정지시사항 확인을 안한다고 가정 + : `${current.reviewer.name} 교수의 ${ + data.stage === "REVISION" ? "확인여부를" : "심사를" + } 수정했습니다.`, }); + setOpen(false); + router.refresh(); })(); }} > @@ -177,17 +210,21 @@ function ModalContent({ open, setOpen, data, current }: ModalProps) { {current.reviewer.name} - - - - + {data.stage === "REVISION" ? ( + + + + ) : ( + + + + )} - {!current.isFinal && data.stage === "MAIN" ? ( @@ -199,7 +236,25 @@ function ModalContent({ open, setOpen, data, current }: ModalProps) { ) : null} - + {data.stage !== "REVISION" && ( + <> + + + + setReviewFile(file)} + disabled={commentType !== "심사 의견 파일"} + /> + + + )} diff --git a/src/app/admin/reviews/[thesisId]/AdminReviewListContent.tsx b/src/app/admin/reviews/[thesisId]/AdminReviewListContent.tsx index 34975f16..2644ac30 100644 --- a/src/app/admin/reviews/[thesisId]/AdminReviewListContent.tsx +++ b/src/app/admin/reviews/[thesisId]/AdminReviewListContent.tsx @@ -1,16 +1,26 @@ "use client"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; import { ClientAxios } from "@/api/ClientAxios"; import { AdminReviewResponse, ThesisReview, UpdateReviewRequestBody } from "@/api/_types/reviews"; import { transactionTask } from "@/api/_utils/task"; import { API_ROUTES } from "@/api/apiRoute"; import { showNotificationSuccess } from "@/components/common/Notifications"; import SectionTitle from "@/components/common/SectionTitle"; -import { BasicRow, ButtonRow, RowGroup, TitleRow } from "@/components/common/rows"; +import { + BasicRow, + ButtonRow, + CommentTypeRow, + FileUploadRow, + RowGroup, + TextAreaRow, + TitleRow, +} from "@/components/common/rows"; import { AdminReviewList } from "@/components/pages/review/Review/ReviewList"; import { StatusButtons } from "@/components/pages/review/Review/StatusButtons"; import { Badge, Button, Modal, Space, Stack } from "@mantine/core"; -import { useState } from "react"; +import { uploadFile } from "@/api/_utils/uploadFile"; export function AdminReviewListContent({ data }: { data: AdminReviewResponse }) { const [open, setOpen] = useState(false); @@ -31,7 +41,7 @@ export function AdminReviewListContent({ data }: { data: AdminReviewResponse }) opened={!!open} onClose={() => setOpen(false)} centered - size="lg" + size="xl" padding="lg" radius="lg" withCloseButton={false} @@ -61,6 +71,15 @@ function ModalContent({ open, setOpen, data, current }: ModalProps) { const [loading, setLoading] = useState(false); const [thesis, setThesis] = useState(current.contentStatus); const [presentation, setPresentation] = useState(current.presentationStatus); + const [comment, setComment] = useState(current.comment); + const [reviewFile, setReviewFile] = useState(); + const [commentType, setCommentType] = useState(); + + const handleCommentChange = (event: React.ChangeEvent) => { + setComment(event.currentTarget.value); + }; + + const router = useRouter(); return ( setLoading(false)); + let fileUUID; + if (reviewFile) { + fileUUID = (await uploadFile(reviewFile)).uuid; + } else if (current.file) { + fileUUID = current.file.uuid ?? undefined; + } + await ClientAxios.put( - API_ROUTES.review.put(current.id), + data.stage === "REVISION" + ? API_ROUTES.review.revision.put(current.id) + : API_ROUTES.review.put(current.id), { - comment: current.comment, + ...(commentType === "심사 의견" ? { comment } : {}), contentStatus: thesis, presentationStatus: presentation, + ...(commentType === "심사 의견 파일" ? { fileUUID } : {}), } satisfies UpdateReviewRequestBody, { baseURL: process.env.NEXT_PUBLIC_REVIEW_API_ENDPOINT } ); showNotificationSuccess({ - message: `${current.reviewer.name} 교수의 심사를 수정했습니다.`, + message: `${current.reviewer.name} 교수의 ${ + data.stage === "REVISION" ? "확인여부를" : "심사를" + } 수정했습니다.`, }); + setOpen(false); + router.refresh(); })(); }} > @@ -107,15 +140,20 @@ function ModalContent({ open, setOpen, data, current }: ModalProps) { - - - + {data.stage === "REVISION" ? ( + + + + ) : ( + + + + )} - {!current.isFinal && data.stage === "MAIN" ? ( @@ -127,7 +165,25 @@ function ModalContent({ open, setOpen, data, current }: ModalProps) { ) : null} - + {data.stage !== "REVISION" && ( + <> + + + + setReviewFile(file)} + disabled={commentType !== "심사 의견 파일"} + /> + + + )} diff --git a/src/app/prof/final/[id]/ProfessorFinalForm.tsx b/src/app/prof/final/[id]/ProfessorFinalForm.tsx index c22e6d10..ef494c99 100644 --- a/src/app/prof/final/[id]/ProfessorFinalForm.tsx +++ b/src/app/prof/final/[id]/ProfessorFinalForm.tsx @@ -53,6 +53,7 @@ export function ProfessorFinalForm({ const { values } = form; const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [currentState, setCurrentState] = useState(null); + const [commentType, setCommentType] = useState(); const handleSubmit = transactionTask(async (task, input: FormInput) => { setCurrentState("pending"); @@ -70,8 +71,8 @@ export function ProfessorFinalForm({ API_ROUTES.review.final.put(reviewId), { contentStatus: input.status, - comment: input.comment, - fileUUID, + ...(commentType === "심사 의견" ? { comment: input.comment } : {}), + ...(commentType === "심사 의견 파일" ? { fileUUID } : {}), } satisfies UpdateReviewRequestBody, { baseURL: process.env.NEXT_PUBLIC_REVIEW_API_ENDPOINT } ); @@ -113,6 +114,8 @@ export function ProfessorFinalForm({ form={form} previousCommentFile={previous.reviewFile ?? undefined} currentState={currentState} + commentType={commentType} + setCommentType={setCommentType} /> - + {!within && ( diff --git a/src/app/prof/review/[id]/ProfessorReviewForm.tsx b/src/app/prof/review/[id]/ProfessorReviewForm.tsx index f3955d9e..e7dd44f6 100644 --- a/src/app/prof/review/[id]/ProfessorReviewForm.tsx +++ b/src/app/prof/review/[id]/ProfessorReviewForm.tsx @@ -61,6 +61,7 @@ export function ProfessorReviewForm({ const { values } = form; const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [currentState, setCurrentState] = useState(null); + const [commentType, setCommentType] = useState(); const handleSubmit = transactionTask(async (task, input: FormInput) => { setCurrentState("pending"); @@ -79,8 +80,8 @@ export function ProfessorReviewForm({ { contentStatus: input.thesis, presentationStatus: input.presentation, - comment: input.comment, - fileUUID, + ...(commentType === "심사 의견" ? { comment: input.comment } : {}), + ...(commentType === "심사 의견 파일" ? { fileUUID } : {}), } satisfies UpdateReviewRequestBody, { baseURL: process.env.NEXT_PUBLIC_REVIEW_API_ENDPOINT } ); @@ -125,6 +126,8 @@ export function ProfessorReviewForm({ form={form} previousCommentFile={previous?.reviewFile ?? undefined} currentState={currentState} + commentType={commentType} + setCommentType={setCommentType} /> - + {!within && ( diff --git a/src/app/prof/revision/[id]/RevisionCheckForm.tsx b/src/app/prof/revision/[id]/RevisionCheckForm.tsx index dbb0a343..88fd0874 100644 --- a/src/app/prof/revision/[id]/RevisionCheckForm.tsx +++ b/src/app/prof/revision/[id]/RevisionCheckForm.tsx @@ -37,13 +37,9 @@ export function RevisionCheckForm({ const handleSubmit = async (input: FormInput) => { setPending(true); try { - await ClientAxios.put( - API_ROUTES.review.revision.put(revisionId), - { - contentStatus: input.checked ? "PASS" : "FAIL", - } satisfies { contentStatus: Status }, - { baseURL: process.env.NEXT_PUBLIC_REVIEW_API_ENDPOINT } - ); + await ClientAxios.put(API_ROUTES.review.revision.put(revisionId), { + contentStatus: input.checked ? "PASS" : "FAIL", + } satisfies { contentStatus: Status }); showNotificationSuccess({ message: input.checked diff --git a/src/app/student/achievement/[id]/page.tsx b/src/app/student/achievement/[id]/page.tsx index 48bf2373..3b17cbc7 100644 --- a/src/app/student/achievement/[id]/page.tsx +++ b/src/app/student/achievement/[id]/page.tsx @@ -15,7 +15,7 @@ async function StudentAchievementPage({ params: { id } }: Props) { <>
- +
); diff --git a/src/app/student/result/page.tsx b/src/app/student/result/page.tsx index 2ae9adaa..9ad71187 100644 --- a/src/app/student/result/page.tsx +++ b/src/app/student/result/page.tsx @@ -7,50 +7,44 @@ import { fetcher } from "@/api/fetcher"; import { API_ROUTES } from "@/api/apiRoute"; import { MyReviewResponse } from "@/api/_types/reviews"; import { ThesisInfoData } from "@/components/pages/review/ThesisInfo/ThesisInfo"; -import { checkPhase } from "@/api/_utils/checkPhase"; -import { PhaseReady } from "@/components/pages/PhaseReady"; -import { formatTime } from "@/components/common/Clock/date/format"; +import { UserResponse } from "@/api/_types/user"; export default async function StudentResultPage() { const { token } = await AuthSSR({ userType: "STUDENT" }); - const { "0": result } = (await fetcher({ url: API_ROUTES.review.getMe(), token })) as { + const user = (await fetcher({ url: API_ROUTES.user.get(), token })) as UserResponse; + const { "0": pre, "1": main } = (await fetcher({ url: API_ROUTES.review.getMe(), token })) as { "0": MyReviewResponse; + "1": MyReviewResponse; }; + const thesisRes: MyReviewResponse = user.currentPhase === "PRELIMINARY" ? pre : main; const thesisInfo: ThesisInfoData = { - title: result.title, - stage: result.stage, + title: thesisRes.title, + stage: thesisRes.stage, studentInfo: { - name: result.student, - department: { name: result.department }, + name: thesisRes.student, + department: { name: thesisRes.department }, }, - abstract: result.abstract, - thesisFile: result.thesisFiles.find((file) => file.type === "THESIS")?.file, - presentationFile: result.thesisFiles.find((file) => file.type === "PRESENTATION")?.file, + abstract: thesisRes.abstract, + thesisFile: thesisRes.thesisFiles.find((file) => file.type === "THESIS")?.file, + presentationFile: thesisRes.thesisFiles.find((file) => file.type === "PRESENTATION")?.file, }; - const { end, after } = await checkPhase({ - title: thesisInfo.stage === "MAIN" ? "본심 최종 심사" : "예심 최종 심사", - token, - }); - - return after ? ( + return ( <> review.isFinal)} + stage={thesisRes.stage} + review={thesisRes.reviews.find((review) => review.isFinal)} /> !review.isFinal)} + stage={thesisRes.stage} + reviews={thesisRes.reviews.filter((review) => !review.isFinal)} /> - ) : ( - ); } diff --git a/src/app/student/write/page.tsx b/src/app/student/write/page.tsx index fcea2edf..ead83b9e 100644 --- a/src/app/student/write/page.tsx +++ b/src/app/student/write/page.tsx @@ -1,5 +1,8 @@ import { AuthSSR } from "@/api/AuthSSR"; +import { UserResponse } from "@/api/_types/user"; import { checkPhase } from "@/api/_utils/checkPhase"; +import { API_ROUTES } from "@/api/apiRoute"; +import { fetcher } from "@/api/fetcher"; import { formatTime } from "@/components/common/Clock/date/format"; import PageHeader from "@/components/common/PageHeader"; import { Section } from "@/components/common/Section"; @@ -8,7 +11,15 @@ import PaperSubmissionForm from "@/components/pages/write/PaperSubmissionForm/Pa export default async function StudentWritePage() { const { token } = await AuthSSR({ userType: "STUDENT" }); - const { within, start, end } = await checkPhase({ title: "논문 제출", token }); + const user = (await fetcher({ url: API_ROUTES.user.get(), token })) as UserResponse; + + const { within, start, end } = await checkPhase({ + title: + user.currentPhase === "MAIN" || user.currentPhase === "REVISION" + ? "본심 논문 제출" + : "예심 논문 제출", + token, + }); return within ? ( <> diff --git a/src/components/common/rows/CommentTypeRow/CommentTypeRow.tsx b/src/components/common/rows/CommentTypeRow/CommentTypeRow.tsx new file mode 100644 index 00000000..b9600afd --- /dev/null +++ b/src/components/common/rows/CommentTypeRow/CommentTypeRow.tsx @@ -0,0 +1,23 @@ +import { Dispatch, SetStateAction } from "react"; +import { Group, Radio, RadioGroup } from "@mantine/core"; +import RowGroup from "@/components/common/rows/RowGroup/RowGroup"; + +interface Props { + commentType?: string; + setCommentType: Dispatch>; +} + +function CommentTypeRow({ commentType, setCommentType }: Props) { + return ( + + + + + + + + + ); +} + +export default CommentTypeRow; diff --git a/src/components/common/rows/FileUploadRow/FileUploadRow.tsx b/src/components/common/rows/FileUploadRow/FileUploadRow.tsx index 2e2a7e2f..e0953912 100644 --- a/src/components/common/rows/FileUploadRow/FileUploadRow.tsx +++ b/src/components/common/rows/FileUploadRow/FileUploadRow.tsx @@ -26,6 +26,7 @@ interface Props { /* eslint-disable @typescript-eslint/no-explicit-any */ form?: UseFormReturnType; formKey?: string; + disabled?: boolean; } /** @@ -43,6 +44,7 @@ function FileUploadRow({ form, formKey = "file", fieldSize = "md", + disabled, }: Props) { const [file, setFile] = useState(null); @@ -76,6 +78,7 @@ function FileUploadRow({ placeholder={usePrevious ? previousFile.name : "파일 업로드..."} {...form?.getInputProps(formKey)} value={form ? formValue || null : file} + disabled={disabled} /> {(!required || usePrevious) && ( @@ -330,30 +299,22 @@ function AssignReviewerSection({
- { setSelectedHeadReviewer((prev) => ({ ...prev, - deptId: value, + profId: value, })); }} - value={selectedHeadReviewer.deptId} /> - {selectedHeadReviewer.deptId ? ( - { - setSelectedHeadReviewer((prev) => ({ - ...prev, - profId: value, - })); - }} - style={{ marginLeft: "10px" }} - value={selectedHeadReviewer.profId} - /> - ) : ( -