diff --git a/src/features/recruit/schemas/jobPostSchema.ts b/src/features/recruit/schemas/jobPostSchema.ts
index 02255a0c..0dbdf9ed 100644
--- a/src/features/recruit/schemas/jobPostSchema.ts
+++ b/src/features/recruit/schemas/jobPostSchema.ts
@@ -3,24 +3,21 @@ import { z } from "zod";
export const jobPostSchema = z.object({
title: z.string().min(1, "공고 제목을 입력해주세요.").max(50, "50자 이내로 작성해주세요."),
occupation: z.array(z.string()).min(1, "직종을 1개 이상 선택해주세요."),
- employmentType: z
- .union([z.enum(["정규직", "계약직"]), z.null()])
- .refine((val) => val !== null, { message: "고용형태를 선택해주세요." }),
+ employmentType: z.enum(["정규직", "계약직"], { message: "고용형태를 선택해주세요." }),
numberOfRecruits: z
.number({ invalid_type_error: "모집인원을 숫자로 입력해주세요." })
.positive("1명 이상 입력해주세요."),
- career: z
- .union([z.enum(["경력", "경력무관"]), z.null()])
- .refine((val) => val !== null, { message: "경력여부를 선택해주세요." }),
- education: z
- .union([z.enum(["고졸", "대졸", "학력무관"]), z.null()])
- .refine((val) => val !== null, { message: "학력을 선택해주세요." }),
+ career: z.enum(["경력", "경력무관"], { message: "경력여부를 선택해주세요." }),
+ education: z.enum(["고졸", "대졸", "학력무관"], { message: "학력을 선택해주세요." }),
location: z.string().min(1, "주소를 입력해주세요."),
locationDetail: z.string().min(1, "상세주소를 입력해주세요."),
deadline: z.string(),
salaryType: z.string(),
salary: z.number({ invalid_type_error: "급여를 숫자로 입력해주세요." }),
workingDays: z.array(z.enum(["월", "화", "수", "목", "금", "토", "일", "요일협의"])),
+ workTimeStart: z.string().optional(),
+ workTimeEnd: z.string().optional(),
+ timeDiscussion: z.boolean().optional(),
// workingHours: z
// .object({
// start: z.string().optional(),
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..46c9c1a0 100644
--- a/src/features/resume/components/ResumeContainer.tsx
+++ b/src/features/resume/components/ResumeContainer.tsx
@@ -1,13 +1,26 @@
-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";
+import { useSession } from "next-auth/react";
type ResumeContainerProps = {
resume: ResumeFormData;
+ userInfo?: {
+ name?: string;
+ phone?: string;
+ email?: string;
+ };
};
-export default function ResumeContainer({ resume }: ResumeContainerProps) {
+export default function ResumeContainer({ resume, userInfo }: ResumeContainerProps) {
+ const { data: session } = useSession();
+
+ // userInfo가 제공되면 우선 사용, 없으면 세션에서 가져오기
+ const displayName = userInfo?.name || session?.user?.name || "";
+ const displayPhone = userInfo?.phone || "";
+ const displayEmail = userInfo?.email || session?.user?.email || "";
+
return (
@@ -15,7 +28,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..0b516e0a 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,231 @@ 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.map((field, idx) => (
+
+ ))}
+
+
+);
+
+interface CertificationsSectionProps {
+ fields: Record<"id", string>[];
+ onAdd: () => void;
+ onRemove: (index: number) => void;
+ isEmpty: boolean;
+}
+
+const CertificationsSection = ({
+ fields,
+ onAdd,
+ onRemove,
+ isEmpty,
+}: CertificationsSectionProps) => (
+
+ 자격증
+ {isEmpty && 자격증이 없습니다. 자격증을 추가해보세요.
}
+ {fields.map((field, idx) => (
+
+ ))}
+
+
+);
+
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 +256,6 @@ export default function ResumeForm({ mode, resumeId, defaultValues }: ResumeForm
defaultValues: defaultValues ?? {
jobCategory: "",
title: "",
- name: "",
- phone: "",
- email: "",
schoolType: "",
schoolName: "",
graduationStatus: "",
@@ -51,73 +270,117 @@ 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 (
diff --git a/src/features/resume/components/common/ErrorMessage.tsx b/src/features/resume/components/common/ErrorMessage.tsx
new file mode 100644
index 00000000..3c20dabc
--- /dev/null
+++ b/src/features/resume/components/common/ErrorMessage.tsx
@@ -0,0 +1,3 @@
+export const ErrorMessage = ({ message }: { message: string }) => (
+