diff --git a/.env.example b/.env.example deleted file mode 100644 index 58965285..00000000 --- a/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -NEXT_PUBLIC_KAKAO_JS_KEY='카카오 디벨로퍼스 시니어내일 계정의 자바스크립트 키입니다.' -NEXT_PUBLIC_API_URL=https://senior-naeil.life/api/ \ No newline at end of file diff --git a/package.json b/package.json index 5283bde2..8c014479 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "dev": "next dev", - "build": "next build || true", + "build": "next build", "start": "next start", "lint": "next lint", "format": "prettier --write ." diff --git a/src/app/[type]/mypage/[userId]/resume/[resumeId]/edit/page.tsx b/src/app/[type]/mypage/[userId]/resume/[resumeId]/edit/page.tsx index 2f48d746..a9107ff9 100644 --- a/src/app/[type]/mypage/[userId]/resume/[resumeId]/edit/page.tsx +++ b/src/app/[type]/mypage/[userId]/resume/[resumeId]/edit/page.tsx @@ -9,10 +9,49 @@ import { resumeApi } from "@/api/resume"; import type { ResumeResponseDto } from "@/types/api/resume"; import type { ResumeFormData } from "@/features/resume/validation/resumeSchema"; +const ErrorCard = ({ + title, + message, + onRetry, + showRetry = true, + actionButton, +}: { + title: string; + message: string; + onRetry?: () => void; + showRetry?: boolean; + actionButton?: React.ReactNode; +}) => ( +
+
+
+
+
⚠️
+

{title}

+

{message}

+ +
+ {showRetry && onRetry && ( + + )} + {actionButton} +
+
+
+
+
+); + export default function ResumeEditPage() { const router = useRouter(); const { data: session, status: sessionStatus } = useSession(); - const { userId, resumeId } = useParams() as { + const { type, userId, resumeId } = useParams() as { + type: string; userId: string; resumeId: string; }; @@ -33,6 +72,7 @@ export default function ResumeEditPage() { data: detailData, isLoading: isDetailLoading, error: detailError, + refetch: refetchDetail, } = useQuery({ queryKey: ["resumeDetail", resumeId], queryFn: () => resumeApi.getDetail(resumeId!, session?.accessToken ?? ""), @@ -42,17 +82,48 @@ export default function ResumeEditPage() { if (sessionStatus === "loading" || isDetailLoading) { return ; } + if (detailError) { - return

이력서 불러오기 실패: {detailError.message}

; + // 확인요망 + const getErrorMessage = (error: Error) => { + if (error.message.includes("404")) { + return "존재하지 않는 이력서이거나 삭제된 이력서입니다."; + } + if (error.message.includes("403")) { + return "이 이력서를 수정할 권한이 없습니다."; + } + return "이력서를 불러오는 중 문제가 발생했습니다. 네트워크 연결을 확인해주세요."; + }; + + return ( + + + + + } + /> + ); } const dto = detailData!.resume; const defaultValues: ResumeFormData = { jobCategory: dto.job_category, title: dto.resume_title, - name: dto.user.name, - phone: dto.user.phone_number, - email: session!.user.email ?? "", schoolType: dto.education_level, schoolName: dto.school_name, graduationStatus: dto.education_state, @@ -69,6 +140,9 @@ export default function ResumeEditPage() { date: c.date_acquired, })), introduction: dto.introduce, + name: "", + phone: "", + email: "", }; return ( diff --git a/src/app/[type]/mypage/[userId]/resume/[resumeId]/page.tsx b/src/app/[type]/mypage/[userId]/resume/[resumeId]/page.tsx index 539b599a..35710812 100644 --- a/src/app/[type]/mypage/[userId]/resume/[resumeId]/page.tsx +++ b/src/app/[type]/mypage/[userId]/resume/[resumeId]/page.tsx @@ -5,7 +5,7 @@ import { useSession } from "next-auth/react"; import Spinner from "@/components/common/Spinner"; import ResumeSelect from "@/features/resume/components/common/ui/ResumeSelect"; import ResumeContainer from "@/features/resume/components/ResumeContainer"; -import ResumeActionButtons from "@/features/resume/components/ResumeActionButton"; +import ResumeActionButtons from "@/features/resume/components/sections/ResumeActionButton"; import { useGetResumeDetail } from "@/features/resume/api/useGetResumeDetail"; import { useGetResumeList } from "@/features/resume/api/useGetResumeList"; import { mapToResumeFormData } from "@/features/resume/utils/mapToResumeFormData"; diff --git a/src/app/globals.css b/src/app/globals.css index acdc3d83..3b6211d2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -86,3 +86,16 @@ .animate-slide-down { animation: slide-up 0.3s ease-out; } + +/* Screen reader only */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/src/components/common/ConfirmModal.tsx b/src/components/common/ConfirmModal.tsx index dc2421d2..94ca6614 100644 --- a/src/components/common/ConfirmModal.tsx +++ b/src/components/common/ConfirmModal.tsx @@ -1,53 +1,61 @@ "use client"; -import { Fragment } from "react"; -import { Transition, TransitionChild, Dialog, Description } from "@headlessui/react"; import { useModalStore } from "@/store/useModalStore"; +import { useEffect } from "react"; export default function ConfirmModal() { const { isOpen, title, message, confirmText, onConfirm, closeModal } = useModalStore(); - return ( - - -
- + const handleConfirm = () => { + onConfirm?.(); + closeModal(); + }; + + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === "Escape" && isOpen) { + closeModal(); + } + }; + + if (isOpen) { + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + document.body.style.overflow = "hidden"; + document.body.style.paddingRight = `${scrollbarWidth}px`; + document.addEventListener("keydown", handleEsc); + } - - -
-

{title}

- - {message} - - -
-
-
- + return () => { + document.body.style.overflow = "unset"; + document.body.style.paddingRight = "0px"; + document.removeEventListener("keydown", handleEsc); + }; + }, [isOpen, closeModal]); + + if (!isOpen) return null; + + return ( +
+
+
+

{title}

+

{message}

+
+ + +
+
+
); } diff --git a/src/features/applicants/components/ApplicantsResume.tsx b/src/features/applicants/components/ApplicantsResume.tsx index d5141900..7f0a7924 100644 --- a/src/features/applicants/components/ApplicantsResume.tsx +++ b/src/features/applicants/components/ApplicantsResume.tsx @@ -1,9 +1,9 @@ "use client"; import { applicantListApi } from "@/api/applicant"; -import ResumeContactSection from "@/features/resume/components/ResumeContactSection"; -import ResumeSelfIntroductionSection from "@/features/resume/components/ResumeSelfIntroductionSection"; -import ResumeTableSection from "@/features/resume/components/ResumeTableSection"; +import ResumeContactSection from "@/features/resume/components/sections/ResumeContactSection"; +import ResumeSelfIntroductionSection from "@/features/resume/components/sections/ResumeSelfIntroductionSection"; +import ResumeTableSection from "@/features/resume/components/sections/ResumeTableSection"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; diff --git a/src/features/auth-common/components/baseFields/FormDatePicker.tsx b/src/features/auth-common/components/baseFields/FormDatePicker.tsx index ea805361..82686559 100644 --- a/src/features/auth-common/components/baseFields/FormDatePicker.tsx +++ b/src/features/auth-common/components/baseFields/FormDatePicker.tsx @@ -1,5 +1,4 @@ "use client"; - import { Controller, useFormContext, FieldValues, Path } from "react-hook-form"; import { CalendarIcon } from "lucide-react"; import DatePicker from "react-datepicker"; @@ -25,6 +24,13 @@ export default function FormDatePicker({ formState: { errors }, } = useFormContext(); + const formatDateToLocal = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + }; + return (
@@ -36,7 +42,7 @@ export default function FormDatePicker({ { - const formatted = date?.toISOString().split("T")[0] || ""; + const formatted = date ? formatDateToLocal(date) : ""; field.onChange(formatted); }} dateFormat="yyyy-MM-dd" diff --git a/src/features/auth-user/ui/signup/UserSignupStepTwoForm.tsx b/src/features/auth-user/ui/signup/UserSignupStepTwoForm.tsx index 17cc2a90..eb897507 100644 --- a/src/features/auth-user/ui/signup/UserSignupStepTwoForm.tsx +++ b/src/features/auth-user/ui/signup/UserSignupStepTwoForm.tsx @@ -117,31 +117,49 @@ export default function SignupStepTwoUser({ onSubmit }: Props) { } onButtonClick={async () => { const rawPhone = getValues("phone"); - if (!rawPhone) { - setError("phone", { - type: "manual", - message: "전화번호를 입력 후 인증을 진행해주세요.", - }); - return; - } + clearErrors("phone"); const payload: PhoneVerificationRequestDto = { - phone_number: rawPhone.replace(/\D/g, ""), + phone_number: rawPhone || "", // 하이픈 포함해서 그대로 전송 join_type: "normal", }; - await userApi.requestPhoneCode(payload); - setIsRequesting(true); - setTimeLeft(120); - setIsVerifyInputVisible(true); - setIsFadingOut(false); - showModal({ - title: "인증번호가 발송되었습니다.", - message: "휴대폰 문자를 확인 후 \n 인증번호를 입력해주세요.", - confirmText: "확인", - onConfirm: () => {}, - }); + try { + await userApi.requestPhoneCode(payload); + + setIsRequesting(true); + setTimeLeft(120); + setIsVerifyInputVisible(true); + setIsFadingOut(false); + showModal({ + title: "인증번호가 발송되었습니다.", + message: "휴대폰 문자를 확인 후 \n 인증번호를 입력해주세요.", + confirmText: "확인", + onConfirm: () => {}, + }); + } catch (error: unknown) { + if ( + error && + typeof error === "object" && + "response" in error && + error.response && + typeof error.response === "object" && + "status" in error.response + ) { + showModal({ + title: "⚠️", + message: "이미 등록된 번호입니다. 다른번호를 입력해주세요", + confirmText: "확인", + onConfirm: () => {}, + }); + } else { + setError("phone", { + type: "manual", + message: "인증 요청 중 오류가 발생했습니다. 다시 시도해주세요.", + }); + } + } }} /> @@ -168,25 +186,60 @@ export default function SignupStepTwoUser({ onSubmit }: Props) { const rawPhone = getValues("phone"); const payload: VerifyCodeRequestDto = { - phone_number: rawPhone.replace(/\D/g, ""), + phone_number: rawPhone || "", code, join_type: "normal", }; - await userApi.verifyPhoneCode(payload); - setIsVerified(true); - setValue("verifyCode", code, { shouldValidate: true }); - setIsFadingOut(true); - setTimeout(() => { - setIsVerifyInputVisible(false); - setIsRequesting(false); - }, 100); - showModal({ - title: "문자인증 성공", - message: "인증이 완료되었습니다. \n 회원가입을 진행해주세요.", - confirmText: "확인", - onConfirm: () => {}, - }); + try { + await userApi.verifyPhoneCode(payload); + + setIsVerified(true); + setValue("verifyCode", code, { shouldValidate: true }); + setIsFadingOut(true); + setTimeout(() => { + setIsVerifyInputVisible(false); + setIsRequesting(false); + }, 100); + showModal({ + title: "문자인증 성공", + message: "인증이 완료되었습니다. \n 나머지 정보를 입력해주세요.", + confirmText: "확인", + onConfirm: () => {}, + }); + } catch (error: unknown) { + if ( + error && + typeof error === "object" && + "response" in error && + error.response && + typeof error.response === "object" && + "status" in error.response + ) { + setError("verifyCode", { + type: "manual", + message: "인증번호가 일치하지 않습니다.", + }); + } else if ( + error && + typeof error === "object" && + "response" in error && + error.response && + typeof error.response === "object" && + "status" in error.response && + error.response.status === 408 + ) { + setError("verifyCode", { + type: "manual", + message: "인증 시간이 만료되었습니다. 다시 요청해주세요.", + }); + } else { + setError("verifyCode", { + type: "manual", + message: "인증 확인 중 오류가 발생했습니다. 다시 시도해주세요.", + }); + } + } }} />
diff --git a/src/features/resume/api/useCreateResume.ts b/src/features/resume/api/useCreateResume.ts index faf8c4a7..ca2c6d3b 100644 --- a/src/features/resume/api/useCreateResume.ts +++ b/src/features/resume/api/useCreateResume.ts @@ -4,20 +4,79 @@ import { resumeApi } from "@/api/resume"; import type { CreateResumeRequestDto, ResumeResponseDto } from "@/types/api/resume"; import type { ApiError } from "@/lib/fetcher"; -export function useCreateResume() { - const { data: session } = useSession(); +interface UseCreateResumeOptions { + onSuccess?: (data: ResumeResponseDto) => void; + onError?: (error: ApiError) => void; +} + +export function useCreateResume(options?: UseCreateResumeOptions) { + const { data: session, status: sessionStatus } = useSession(); - const { mutate, status, error } = useMutation< + const { mutate, status, error, reset } = useMutation< ResumeResponseDto, ApiError, CreateResumeRequestDto >({ - mutationFn: (dto) => resumeApi.create(dto, session?.accessToken ?? ""), + mutationFn: async (dto) => { + // 세션 상태 확인 + if (sessionStatus === "loading") { + throw new Error("인증 정보를 확인하는 중입니다. 잠시 후 다시 시도해주세요."); + } + + if (!session?.accessToken) { + throw new Error("로그인이 필요합니다. 다시 로그인해주세요."); + } + + // DTO 검증 + try { + return await resumeApi.create(dto, session.accessToken); + } catch (error) { + // API 에러를 사용자 친화적 메시지로 변환 + if (error instanceof Error) { + if (error.message.includes("401") || error.message.includes("Unauthorized")) { + throw new Error("인증이 만료되었습니다. 다시 로그인해주세요."); + } + if (error.message.includes("403") || error.message.includes("Forbidden")) { + throw new Error("이력서 생성 권한이 없습니다."); + } + if (error.message.includes("400") || error.message.includes("Bad Request")) { + throw new Error("입력 정보가 올바르지 않습니다. 다시 확인해주세요."); + } + if (error.message.includes("409") || error.message.includes("Conflict")) { + throw new Error("동일한 제목의 이력서가 이미 존재합니다."); + } + if (error.message.includes("500") || error.message.includes("Internal Server Error")) { + throw new Error("서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + } + } + throw error; + } + }, + onSuccess: (data) => { + options?.onSuccess?.(data); + }, + onError: (error) => { + console.error("이력서 생성 실패:", error); + options?.onError?.(error); + }, + // 재시도 설정 + retry: (failureCount, error) => { + // 인증 오류나 권한 오류는 재시도하지 않음 + if (error.message.includes("401") || error.message.includes("403")) { + return false; + } + // 최대 2번까지 재시도 + return failureCount < 2; + }, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000), }); return { createResume: mutate, isLoading: status === "pending", + isSuccess: status === "success", + isError: status === "error", error, + reset, }; } diff --git a/src/features/resume/api/useUpdateResume.ts b/src/features/resume/api/useUpdateResume.ts index 99bd7fc9..28acaebd 100644 --- a/src/features/resume/api/useUpdateResume.ts +++ b/src/features/resume/api/useUpdateResume.ts @@ -4,18 +4,91 @@ import { resumeApi } from "@/api/resume"; import type { ApiError } from "@/lib/fetcher"; import type { UpdateResumeRequestDto, ResumeResponseDto } from "@/types/api/resume"; -export function useUpdateResume() { - const { data: session } = useSession(); +interface UseUpdateResumeOptions { + onSuccess?: (data: ResumeResponseDto) => void; + onError?: (error: ApiError) => void; +} + +export function useUpdateResume(options?: UseUpdateResumeOptions) { + const { data: session, status: sessionStatus } = useSession(); const { mutate: updateResume, status, error, + reset, } = useMutation({ - mutationFn: ({ id, dto }) => resumeApi.update(id, dto, session?.accessToken ?? ""), + mutationFn: async ({ id, dto }) => { + // 세션 상태 확인 + if (sessionStatus === "loading") { + throw new Error("인증 정보를 확인하는 중입니다. 잠시 후 다시 시도해주세요."); + } + + if (!session?.accessToken) { + throw new Error("로그인이 필요합니다. 다시 로그인해주세요."); + } + // 파라미터 검증 + if (!id?.trim()) { + throw new Error("이력서 ID가 필요합니다."); + } + try { + return await resumeApi.update(id, dto, session.accessToken); + } catch (error) { + // API 에러를 사용자 친화적 메시지로 변환 + if (error instanceof Error) { + if (error.message.includes("401") || error.message.includes("Unauthorized")) { + throw new Error("인증이 만료되었습니다. 다시 로그인해주세요."); + } + if (error.message.includes("403") || error.message.includes("Forbidden")) { + throw new Error("이력서 수정 권한이 없습니다."); + } + if (error.message.includes("404") || error.message.includes("Not Found")) { + throw new Error("수정하려는 이력서를 찾을 수 없습니다."); + } + if (error.message.includes("400") || error.message.includes("Bad Request")) { + throw new Error("입력 정보가 올바르지 않습니다. 다시 확인해주세요."); + } + if (error.message.includes("409") || error.message.includes("Conflict")) { + throw new Error("동일한 제목의 이력서가 이미 존재합니다."); + } + if (error.message.includes("500") || error.message.includes("Internal Server Error")) { + throw new Error("서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + } + } + throw error; + } + }, + onSuccess: (data) => { + options?.onSuccess?.(data); + }, + onError: (error) => { + console.error("이력서 수정 실패:", error); + options?.onError?.(error); + }, + // 재시도 설정 + retry: (failureCount, error) => { + // 인증 오류, 권한 오류, 404 오류는 재시도하지 않음 + if ( + error.message.includes("401") || + error.message.includes("403") || + error.message.includes("404") + ) { + return false; + } + // 최대 2번까지 재시도 + return failureCount < 2; + }, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000), }); const isUpdating = status === "pending"; - return { updateResume, isUpdating, error }; + return { + updateResume, + isUpdating, + isSuccess: status === "success", + isError: status === "error", + error, + reset, + }; } diff --git a/src/features/resume/components/ResumeContainer.tsx b/src/features/resume/components/ResumeContainer.tsx index 6d768b5c..c3ef8f0d 100644 --- a/src/features/resume/components/ResumeContainer.tsx +++ b/src/features/resume/components/ResumeContainer.tsx @@ -1,6 +1,6 @@ -import ResumeContactSection from "@/features/resume/components/ResumeContactSection"; -import ResumeSelfIntroductionSection from "@/features/resume/components/ResumeSelfIntroductionSection"; -import ResumeTableSection from "@/features/resume/components/ResumeTableSection"; +import ResumeContactSection from "@/features/resume/components/sections/ResumeContactSection"; +import ResumeSelfIntroductionSection from "@/features/resume/components/sections/ResumeSelfIntroductionSection"; +import ResumeTableSection from "@/features/resume/components/sections/ResumeTableSection"; import { ResumeFormData } from "@/features/resume/validation/resumeSchema"; type ResumeContainerProps = { @@ -53,7 +53,7 @@ export default function ResumeContainer({ resume }: ResumeContainerProps) { /> ))} - +
); diff --git a/src/features/resume/components/ResumeForm.tsx b/src/features/resume/components/ResumeForm.tsx index a44fd2e9..b51e4444 100644 --- a/src/features/resume/components/ResumeForm.tsx +++ b/src/features/resume/components/ResumeForm.tsx @@ -1,5 +1,17 @@ "use client"; -import { useForm, FormProvider, useFieldArray } from "react-hook-form"; +import { + useForm, + FormProvider, + useFieldArray, + SubmitHandler, + Control, + UseFormRegister, + UseFormWatch, + Path, +} from "react-hook-form"; +import { useModalStore } from "@/store/useModalStore"; +import { useState, useCallback, useMemo } from "react"; +import { toast } from "react-hot-toast"; import { zodResolver } from "@hookform/resolvers/zod"; import { resumeSchema, ResumeFormData } from "@/features/resume/validation/resumeSchema"; import { useRouter, useParams } from "next/navigation"; @@ -12,21 +24,305 @@ import Input from "@/features/resume/components/common/ui/Input"; import TextArea from "@/features/resume/components/common/ui/TextArea"; import DatePickerField from "@/features/resume/components/common/ui/DatePicker"; import CustomSelect from "@/features/resume/components/common/ui/Select"; +import { LoadingSpinner } from "@/features/resume/components/common/LoadingSpinner"; +import { ErrorMessage } from "@/features/resume/components/common/ErrorMessage"; +import { + SCHOOL_TYPE_OPTIONS, + GRADUATION_STATUS_OPTIONS, +} from "@/features/resume/constants/options"; +import { + ResumeFormProps, + ResumeApiResponse, + ApiError, + ExperienceFormData, + CertificationFormData, +} from "@/features/resume/types"; -interface ResumeFormProps { - mode: "create" | "edit"; - resumeId?: string; - defaultValues?: ResumeFormData; +function useExperienceField(control: Control) { + const { fields, append, remove } = useFieldArray({ + control, + name: "experiences", + }); + const { showModal } = useModalStore(); + + const handleAdd = useCallback(() => { + const newExperience: ExperienceFormData = { + company: "", + position: "", + startDate: "", + endDate: "", + isCurrent: false, + }; + append(newExperience); + }, [append]); + + const handleRemove = useCallback( + (index: number) => { + showModal({ + title: "경력 삭제", + message: "이 경력을 삭제하시겠습니까?\n삭제된 내용은 복구할 수 없습니다.", + confirmText: "삭제", + onConfirm: () => remove(index), + }); + }, + [remove, showModal], + ); + + return { fields, handleAdd, handleRemove, isEmpty: fields.length === 0 }; +} + +function useCertificationField(control: Control) { + const { fields, append, remove } = useFieldArray({ + control, + name: "certifications", + }); + const { showModal } = useModalStore(); + + const handleAdd = useCallback(() => { + const newCertification: CertificationFormData = { + name: "", + issuer: "", + date: "", + }; + append(newCertification); + }, [append]); + + const handleRemove = useCallback( + (index: number) => { + showModal({ + title: "자격증 삭제", + message: "이 자격증을 삭제하시겠습니까?\n삭제된 내용은 복구할 수 없습니다.", + confirmText: "삭제", + onConfirm: () => remove(index), + }); + }, + [remove, showModal], + ); + + return { fields, handleAdd, handleRemove, isEmpty: fields.length === 0 }; +} + +interface ExperiencesSectionProps { + fields: Record<"id", string>[]; + onAdd: () => void; + onRemove: (index: number) => void; + watch: UseFormWatch; + register: UseFormRegister; + isEmpty: boolean; } +const ExperiencesSection = ({ + fields, + onAdd, + onRemove, + watch, + register, + isEmpty, +}: ExperiencesSectionProps) => ( +
+

+ 경력 사항 +

+
+ {isEmpty + ? "경력 사항이 없습니다. 경력 추가 버튼을 눌러 경력을 추가해보세요." + : `현재 ${fields.length}개의 경력이 등록되어 있습니다.`} +
+ + {isEmpty && ( + + )} + +
+ {fields.map((field, idx) => ( +
+ + +
+ 이 버튼을 누르면 {idx + 1}번째 경력 정보가 완전히 삭제됩니다. +
+ +
+ + +
+
+ + )} + /> +
+
+ 현재 근무 상태 + )} + className="w-5 h-5 accent-primary" + aria-describedby={`current-work-help-${idx}`} + /> + +
+ 체크하면 근무 종료일 입력이 비활성화됩니다. +
+
+
+ ))} +
+ + +
+ 새로운 경력 정보를 입력할 수 있는 섹션이 추가됩니다. +
+
+); + +interface CertificationsSectionProps { + fields: Record<"id", string>[]; + onAdd: () => void; + onRemove: (index: number) => void; + isEmpty: boolean; +} + +const CertificationsSection = ({ + fields, + onAdd, + onRemove, + isEmpty, +}: CertificationsSectionProps) => ( +
+

+ 자격증 +

+
+ {isEmpty + ? "자격증이 없습니다. 자격증 추가 버튼을 눌러 자격증을 추가해보세요." + : `현재 ${fields.length}개의 자격증이 등록되어 있습니다.`} +
+ + {isEmpty && ( + + )} + +
+ {fields.map((field, idx) => ( +
+ + +
+ 이 버튼을 누르면 {idx + 1}번째 자격증 정보가 완전히 삭제됩니다. +
+ + +
+ + +
+
+ ))} +
+ + +
+ 새로운 자격증 정보를 입력할 수 있는 섹션이 추가됩니다. +
+
+); + export default function ResumeForm({ mode, resumeId, defaultValues }: ResumeFormProps) { const router = useRouter(); const params = useParams<{ type: string; userId: string }>(); - if (!params) { - router.replace("/auth/login"); - return null; - } const qc = useQueryClient(); + const [submitError, setSubmitError] = useState(null); + + const isValidParams = useMemo(() => { + return !!(params?.type && params?.userId); + }, [params]); + + if (!isValidParams) { + return ( +
+

잘못된 접근입니다.

+ +
+ ); + } const methods = useForm({ resolver: zodResolver(resumeSchema), @@ -34,9 +330,6 @@ export default function ResumeForm({ mode, resumeId, defaultValues }: ResumeForm defaultValues: defaultValues ?? { jobCategory: "", title: "", - name: "", - phone: "", - email: "", schoolType: "", schoolName: "", graduationStatus: "", @@ -51,77 +344,139 @@ export default function ResumeForm({ mode, resumeId, defaultValues }: ResumeForm handleSubmit, watch, setValue, + register, formState: { isSubmitting, errors }, + trigger, } = methods; const { createResume, isLoading: isCreating, error: createError } = useCreateResume(); const { updateResume, isUpdating, error: updateError } = useUpdateResume(); - const { - fields: expFields, - append: addExperience, - remove: removeExperience, - } = useFieldArray({ control, name: "experiences" }); - const { - fields: certFields, - append: addCertification, - remove: removeCertification, - } = useFieldArray({ control, name: "certifications" }); - - const selectedSchoolType = watch("schoolType"); - const selectedGraduationStatus = watch("graduationStatus"); - - const onSubmit = (data: ResumeFormData) => { - if (mode === "create") { - const dto = mapToCreateDto(data); - createResume(dto, { - onSuccess: (res) => { - const newId = res.resume.resume_id; - qc.invalidateQueries({ queryKey: ["resumeList"] }); - qc.invalidateQueries({ queryKey: ["resumeDetail", newId] }); - router.push(`/${params.type}/mypage/${params.userId}/resume/${newId}`); - }, - onError: (err) => console.error("이력서 생성 실패:", err.message), - }); - } else { - const dto = mapToUpdateDto(data, resumeId!); - updateResume( - { id: resumeId!, dto }, - { - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["resumeDetail", resumeId] }); - router.push(`/${params.type}/mypage/${params.userId}/resume/${resumeId}`); - }, - onError: (err) => console.error("이력서 수정 실패:", err.message), - }, - ); - } - }; + const experiencesField = useExperienceField(control); + const certificationsField = useCertificationField(control); + + const watchedValues = watch(["schoolType", "graduationStatus"]); + const [selectedSchoolType, selectedGraduationStatus] = watchedValues; + + const handleSuccess = useCallback( + (res: ResumeApiResponse) => { + setSubmitError(null); + + if (mode === "create") { + const newId = res.resume.resume_id; + qc.invalidateQueries({ queryKey: ["resumeList"] }); + qc.invalidateQueries({ queryKey: ["resumeDetail", newId] }); + toast.success("이력서가 성공적으로 생성되었습니다!"); + router.push(`/${params.type}/mypage/${params.userId}/resume/${newId}`); + } else { + qc.invalidateQueries({ queryKey: ["resumeDetail", resumeId] }); + toast.success("이력서가 성공적으로 수정되었습니다!"); + router.push(`/${params.type}/mypage/${params.userId}/resume/${resumeId}`); + } + }, + [mode, qc, params, resumeId, router], + ); + + const handleError = useCallback( + (err: ApiError | Error | unknown) => { + let errorMessage = `이력서 ${mode === "create" ? "생성" : "수정"}에 실패했습니다.`; + + if (err && typeof err === "object" && "message" in err && typeof err.message === "string") { + errorMessage = err.message; + } + + setSubmitError(errorMessage); + toast.error(errorMessage); + console.error(`이력서 ${mode === "create" ? "생성" : "수정"} 실패:`, err); + }, + [mode], + ); + + const onSubmit: SubmitHandler = useCallback( + async (data: ResumeFormData) => { + try { + setSubmitError(null); + + const isValid = await trigger(); + if (!isValid) { + setSubmitError("입력 정보를 다시 확인해주세요."); + return; + } + + if (mode === "create") { + const dto = mapToCreateDto(data); + createResume(dto, { + onSuccess: handleSuccess, + onError: handleError, + }); + } else { + if (!resumeId) { + setSubmitError("이력서 ID가 없습니다."); + return; + } + + const dto = mapToUpdateDto(data, resumeId); + updateResume( + { id: resumeId, dto }, + { + onSuccess: handleSuccess, + onError: handleError, + }, + ); + } + } catch (error) { + handleError(error); + } + }, + [mode, resumeId, trigger, createResume, updateResume, handleSuccess, handleError], + ); const isLoading = mode === "create" ? isCreating : isUpdating; - const error = mode === "create" ? createError : updateError; + const apiError = mode === "create" ? createError : updateError; return ( -
+
- {error &&
{error.message}
} +
+ 이력서 {mode === "create" ? "작성" : "수정"} 폼입니다. 모든 필드를 입력한 후 완료 버튼을 + 눌러주세요. +
-

직종

- + {(submitError || apiError) && ( +
+ +
+ )} -

이력서 제목

- +
+

+ 직종 +

+ +
-
-

기본 정보

- - - +
+

+ 이력서 제목 +

+
-
-

학력 사항

+
+

+ 학력 사항 +

setValue("schoolType", val, { shouldValidate: true, shouldTouch: true }) } - options={[ - { label: "고등학교", value: "고등학교" }, - { label: "대학교(2,3년)", value: "대학교(2,3년)" }, - { label: "대학교(4년)", value: "대학교(4년)" }, - { label: "대학원", value: "대학원" }, - ]} + options={SCHOOL_TYPE_OPTIONS} error={errors.schoolType?.message} + aria-label="학교 구분" /> setValue("graduationStatus", val, { shouldValidate: true, shouldTouch: true }) } - options={[ - { label: "졸업", value: "졸업" }, - { label: "재학", value: "재학" }, - { label: "중퇴", value: "중퇴" }, - { label: "휴학", value: "휴학" }, - ]} + options={GRADUATION_STATUS_OPTIONS} error={errors.graduationStatus?.message} + aria-label="졸업 상태" />
-
-

경력 사항

- {expFields.map((field, idx) => ( -
- -
- - -
-
- - -
- -
- ))} - -
+
+ +
-
-

자격증

- {certFields.map((field, idx) => ( -
- - -
- - -
-
- ))} - -
+
+ +
-
-

자기소개

+
+

+ 자기소개 +