();
+ 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}번째 경력 정보가 완전히 삭제됩니다.
+
+
+
+
+
+
+
+
+ )}
+ />
+
+
+
+ ))}
+
+
+
+
+ 새로운 경력 정보를 입력할 수 있는 섹션이 추가됩니다.
+
+
+);
+
+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 (
-
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 }) => (
+ {message}
+);
diff --git a/src/features/resume/components/common/LoadingSpinner.tsx b/src/features/resume/components/common/LoadingSpinner.tsx
new file mode 100644
index 00000000..281729b6
--- /dev/null
+++ b/src/features/resume/components/common/LoadingSpinner.tsx
@@ -0,0 +1,5 @@
+export const LoadingSpinner = () => (
+
+);
diff --git a/src/features/resume/components/common/ui/DatePicker.tsx b/src/features/resume/components/common/ui/DatePicker.tsx
index f88bb78b..567fcfbc 100644
--- a/src/features/resume/components/common/ui/DatePicker.tsx
+++ b/src/features/resume/components/common/ui/DatePicker.tsx
@@ -30,6 +30,22 @@ type DatePickerFieldProps = {
className?: string;
} & VariantProps;
+const formatDateToLocalString = (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}`;
+};
+
+const parseLocalDateString = (dateString: string): Date | null => {
+ if (!dateString) return null;
+
+ const [year, month, day] = dateString.split("-").map(Number);
+ if (!year || !month || !day) return null;
+
+ return new Date(year, month - 1, day);
+};
+
export default function DatePickerField({
label,
name,
@@ -54,9 +70,9 @@ export default function DatePickerField({
render={({ field }) => (
{
- const formatted = date?.toISOString().split("T")[0] || "";
+ const formatted = date ? formatDateToLocalString(date) : "";
field.onChange(formatted);
}}
dateFormat="yyyy-MM-dd"
diff --git a/src/features/resume/components/common/ui/Input.tsx b/src/features/resume/components/common/ui/Input.tsx
index 250094d8..55cce3e0 100644
--- a/src/features/resume/components/common/ui/Input.tsx
+++ b/src/features/resume/components/common/ui/Input.tsx
@@ -10,6 +10,7 @@ const inputVariants = cva(
variant: {
default: "border-gray-300 bg-white focus:border-2 focus:border-primary",
disabled: "border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed",
+ error: "border-red-500 bg-white focus:border-2 focus:border-red-500",
},
},
defaultVariants: {
@@ -25,6 +26,8 @@ type InputProps = {
placeholder?: string;
disabled?: boolean;
value?: string;
+ "aria-label"?: string;
+ "aria-describedby"?: string;
} & VariantProps;
export default function Input({
@@ -35,26 +38,62 @@ export default function Input({
disabled = false,
value,
variant,
+ "aria-label": ariaLabel,
+ "aria-describedby": ariaDescribedBy,
}: InputProps) {
const {
register,
formState: { errors },
} = useFormContext();
+ const error = errors[name];
+ const hasError = !!error;
+ const errorId = `${name}-error`;
+ const helpId = `${name}-help`;
+
+ // aria-describedby 조합
+ const describedBy = [ariaDescribedBy, hasError ? errorId : null, helpId]
+ .filter(Boolean)
+ .join(" ");
+
return (
-
+ {label && (
+
+ )}
- {errors[name] &&
{String(errors[name]?.message)}
}
+
+
+ {placeholder && `예시: ${placeholder}`}
+
+
+ {hasError && (
+
+ {String(error?.message)}
+
+ )}
);
}
diff --git a/src/features/resume/components/common/ui/Select.tsx b/src/features/resume/components/common/ui/Select.tsx
index ca473d04..55365dcf 100644
--- a/src/features/resume/components/common/ui/Select.tsx
+++ b/src/features/resume/components/common/ui/Select.tsx
@@ -1,5 +1,5 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef, useState, useCallback } from "react";
import { ChevronDown } from "lucide-react";
type Option = {
@@ -15,6 +15,7 @@ interface CustomSelectProps {
onChange?: (value: string) => void;
value?: string;
error?: string;
+ "aria-label"?: string;
}
export default function CustomSelect({
@@ -25,48 +26,147 @@ export default function CustomSelect({
onChange,
value,
error,
+ "aria-label": ariaLabel,
}: CustomSelectProps) {
const [isOpen, setIsOpen] = useState(false);
+ const [focusedIndex, setFocusedIndex] = useState(-1);
const selectRef = useRef(null);
+ const buttonRef = useRef(null);
+ const listRef = useRef(null);
const selectedLabel = options.find((opt) => opt.value === value)?.label;
+ const selectedIndex = options.findIndex((opt) => opt.value === value);
- const handleSelect = (val: string) => {
- onChange?.(val);
- setIsOpen(false);
- };
+ const handleSelect = useCallback(
+ (val: string) => {
+ onChange?.(val);
+ setIsOpen(false);
+ setFocusedIndex(-1);
+ buttonRef.current?.focus();
+ },
+ [onChange],
+ );
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (disabled) return;
+
+ switch (e.key) {
+ case "Enter":
+ case " ":
+ e.preventDefault();
+ if (isOpen && focusedIndex >= 0) {
+ handleSelect(options[focusedIndex].value);
+ } else {
+ setIsOpen(true);
+ setFocusedIndex(selectedIndex >= 0 ? selectedIndex : 0);
+ }
+ break;
+ case "Escape":
+ e.preventDefault();
+ setIsOpen(false);
+ setFocusedIndex(-1);
+ buttonRef.current?.focus();
+ break;
+ case "ArrowDown":
+ e.preventDefault();
+ if (!isOpen) {
+ setIsOpen(true);
+ setFocusedIndex(selectedIndex >= 0 ? selectedIndex : 0);
+ } else {
+ setFocusedIndex((prev) => Math.min(prev + 1, options.length - 1));
+ }
+ break;
+ case "ArrowUp":
+ e.preventDefault();
+ if (!isOpen) {
+ setIsOpen(true);
+ setFocusedIndex(selectedIndex >= 0 ? selectedIndex : options.length - 1);
+ } else {
+ setFocusedIndex((prev) => Math.max(prev - 1, 0));
+ }
+ break;
+ case "Home":
+ e.preventDefault();
+ if (isOpen) {
+ setFocusedIndex(0);
+ }
+ break;
+ case "End":
+ e.preventDefault();
+ if (isOpen) {
+ setFocusedIndex(options.length - 1);
+ }
+ break;
+ }
+ },
+ [disabled, isOpen, focusedIndex, selectedIndex, options, handleSelect],
+ );
+
+ useEffect(() => {
+ if (isOpen && focusedIndex >= 0 && listRef.current) {
+ const focusedOption = listRef.current.children[focusedIndex] as HTMLElement;
+ if (focusedOption) {
+ focusedOption.scrollIntoView({ block: "nearest" });
+ }
+ }
+ }, [isOpen, focusedIndex]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (selectRef.current && !selectRef.current.contains(e.target as Node)) {
setIsOpen(false);
+ setFocusedIndex(-1);
}
};
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, []);
+ const hasError = !!error;
+ const errorId = `${label.replace(/\s+/g, "-")}-error`;
+ const helpId = `${label.replace(/\s+/g, "-")}-help`;
+ const listboxId = `${label.replace(/\s+/g, "-")}-listbox`;
+
return (
-