diff --git a/src/app/(pages)/(addform)/addform/page.tsx b/src/app/(pages)/(addform)/addform/page.tsx deleted file mode 100644 index 7b8675d3..00000000 --- a/src/app/(pages)/(addform)/addform/page.tsx +++ /dev/null @@ -1,226 +0,0 @@ -"use client"; -import { useState, useCallback } from "react"; -import { useRouter } from "next/navigation"; -import { useForm } from "react-hook-form"; -import axios from "axios"; -import TabMenuDropdown from "@/app/components/button/dropdown/TabMenuDropdown"; -import RecruitCondition from "./sections/RecruitCondition"; -import WorkCondition from "./sections/WorkCondition"; -import Button from "@/app/components/button/default/Button"; -import { toast } from "react-hot-toast"; -import { RecruitConditionFormData, RecruitContentFormData, WorkConditionFormData } from "@/types/addform"; -import RecruitContent from "./sections/RecruitContent"; - -type FormDataType = { - recruitContent: RecruitContentFormData; - recruitCondition: RecruitConditionFormData; - workCondition: WorkConditionFormData; -}; - -export default function AddFormPage() { - const router = useRouter(); - - const { - formState: { isDirty, isValid }, - } = useForm({ - mode: "onChange", - }); - - const [selectedOption, setSelectedOption] = useState("모집 내용"); - const [imageFiles] = useState([]); - const [formData, setFormData] = useState<{ - recruitContent: RecruitContentFormData; - recruitCondition: RecruitConditionFormData; - workCondition: WorkConditionFormData; - }>({ - recruitContent: { - title: "", - description: "", - recruitmentStartDate: undefined, - recruitmentEndDate: undefined, - imageUrls: [], - }, - recruitCondition: { - numberOfPositions: 0, - gender: "", - education: "", - age: "", - preferred: "", - }, - workCondition: { - hourlyWage: 0, - isNegotiableWorkDays: false, - workDays: [], - workEndTime: "", - workStartTime: "", - workEndDate: "", - workStartDate: "", - location: "", - }, - }); - - const handleOptionChange = (option: string) => { - setSelectedOption(option); - const params = { - "모집 내용": "recruit-content", - "모집 조건": "recruit-condition", - "근무 조건": "work-condition", - }[option]; - router.replace(`/addform?tab=${params}`); - console.log(option); - console.log(params); - }; - - const renderChildren = () => { - switch (selectedOption) { - case "모집 내용": - return ( - handleFormUpdate("recruitContent", data)} - /> - ); - case "모집 조건": - return ( - handleFormUpdate("recruitCondition", data)} - /> - ); - case "근무 조건": - return ( - handleFormUpdate("workCondition", data)} - /> - ); - default: - return <>; - } - }; - - // 이미지 업로드 api - const uploadImages = async (files: File[]) => { - const uploadedUrls: string[] = []; - // 전체 파일 배열을 순회하면서 업로드 로직 진행 - for (const file of files) { - // 파일 크기 체크 - const maxSize = 5 * 1024 * 1024; // 5MB - if (file.size > maxSize) { - toast.error(`5MB 이상의 파일은 업로드할 수 없습니다.`); - continue; - } - const formData = new FormData(); - formData.append("image", file, file.name); - try { - const response = await axios.post(`/api/images/upload`, formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - transformRequest: [(data) => data], - }); - console.log("response", response); - if (response.data.url) { - uploadedUrls.push(response.data.url); - } - } catch (uploadError) { - console.error(`파일 ${file.name} 업로드 실패:`, uploadError); - toast.error(`${file.name} 업로드에 실패했습니다.`); - } - } - return uploadedUrls; - }; - - const handleFormUpdate = useCallback((section: string, data: any) => { - setFormData((prev) => ({ - ...prev, - [section]: data, - })); - }, []); - - const onSubmit = async () => { - try { - // 이미지 업로드 처리 - if (imageFiles.length > 0) { - const uploadedUrls = await uploadImages(imageFiles); - formData.recruitContent.imageUrls = uploadedUrls; - } - - // API 호출 - await axios.post("/api/forms", formData); - window.localStorage.removeItem("tempAddFormData"); - toast.success("알바폼을 등록했습니다."); - router.back(); - } catch (error) { - console.error("에러가 발생했습니다.", error); - toast.error("에러가 발생했습니다."); - onTempSave(formData); - } - }; - - const onTempSave = async (data: FormDataType) => { - if ("imageUrls" in data.recruitContent) { - data.recruitContent.imageUrls = data.recruitContent.imageUrls || []; - - if (imageFiles && imageFiles.length > 0) { - try { - const uploadedUrls = await uploadImages(imageFiles); - if (uploadedUrls.length > 0) { - data.recruitContent.imageUrls = [...data.recruitContent.imageUrls, ...uploadedUrls]; - } else { - data.recruitContent.imageUrls = []; - toast.error("이미지 업로드에 실패했습니다."); - } - } catch (error) { - console.error("이미지 업로드 중 오류 발생:", error); - toast.error("이미지 업로드 중 오류가 발생했습니다."); - data.recruitContent.imageUrls = []; - } - } - } - - window.localStorage.setItem("tempAddFormData", JSON.stringify(data)); - toast.success("임시 저장되었습니다."); - console.log(data); - }; - - return ( - <> - - - {renderChildren()} -
- - -
- - ); -} diff --git a/src/app/(pages)/(addform)/addform/sections/RecruitCondition.tsx b/src/app/(pages)/(addform)/addform/sections/RecruitCondition.tsx deleted file mode 100644 index 07839546..00000000 --- a/src/app/(pages)/(addform)/addform/sections/RecruitCondition.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; -import { RecruitConditionFormData } from "@/types/addform"; -import { useForm, FormProvider } from "react-hook-form"; -import { useEffect } from "react"; -import Label from "../../component/Label"; -import InputDropdown from "@/app/components/button/dropdown/InputDropdown"; - -interface RecruitConditionProps { - formData: RecruitConditionFormData; - onUpdate: (data: RecruitConditionFormData) => void; -} - -// 알바폼 만들기2 - 사장님 -export default function RecruitCondition({ formData, onUpdate }: RecruitConditionProps) { - const methods = useForm({ - mode: "onChange", - defaultValues: formData, - }); - - const { - register, - handleSubmit, - formState: { errors, isDirty }, - watch, - } = methods; - - useEffect(() => { - if (formData) { - methods.reset(formData); - } - }, [formData, methods]); - useEffect(() => { - const subscription = watch((value) => { - if (isDirty) { - onUpdate(value as RecruitConditionFormData); - } - }); - return () => subscription.unsubscribe(); - }, [watch, onUpdate, isDirty]); - const onSubmit = async (data: RecruitConditionFormData) => { - onUpdate(data); - }; - - return ( -
- -
- - - - - - - - - - - - - - - -
-
- ); -} diff --git a/src/app/(pages)/(addform)/addform/sections/RecruitContent.tsx b/src/app/(pages)/(addform)/addform/sections/RecruitContent.tsx deleted file mode 100644 index ebfcfb8b..00000000 --- a/src/app/(pages)/(addform)/addform/sections/RecruitContent.tsx +++ /dev/null @@ -1,114 +0,0 @@ -"use client"; -import Label from "../../component/Label"; -import BaseInput from "@/app/components/input/text/BaseInput"; -import BaseTextArea from "@/app/components/input/textarea/BaseTextArea"; -import ImageInput from "@/app/components/input/file/ImageInput/ImageInput"; -import DatePickerInput from "@/app/components/input/dateTimeDaypicker/DatePickerInput"; -import { useForm, FormProvider } from "react-hook-form"; -import { useEffect, useState } from "react"; -import { RecruitContentFormData } from "@/types/addform"; -import { cn } from "@/lib/tailwindUtil"; - -interface RecruitContentProps { - formData: RecruitContentFormData; - onUpdate: (data: RecruitContentFormData) => void; -} - -export default function RecruitContent({ formData, onUpdate }: RecruitContentProps) { - const methods = useForm({ - mode: "onChange", - defaultValues: formData, - }); - - const [, setImageFiles] = useState([]); - const { - register, - handleSubmit, - formState: { errors, isDirty }, - setValue, - watch, - } = methods; - - // 초기 렌더링시에만 실행되도록 수정 - useEffect(() => { - if (formData) { - methods.reset(formData); - } - }, [formData, methods]); - - // 폼 값 변경 감지를 위한 별도의 useEffect - useEffect(() => { - const subscription = watch((value) => { - if (isDirty) { - onUpdate(value as RecruitContentFormData); - } - }); - - return () => subscription.unsubscribe(); - }, [watch, onUpdate, isDirty]); - - const onSubmit = async (data: RecruitContentFormData) => { - onUpdate(data); - }; - const [recruitmentDateRange, setRecruitmentDateRange] = useState<[Date | null, Date | null]>([null, null]); - const handleRecruitmentDateChange = (dates: [Date | null, Date | null]) => { - setRecruitmentDateRange(dates); - const [start, end] = dates; - if (start) setValue("recruitmentStartDate", start.toISOString()); - if (end) setValue("recruitmentEndDate", end.toISOString()); - }; - const errorTextStyle = - "absolute -bottom-[26px] right-1 text-[13px] text-sm font-medium leading-[22px] text-state-error lg:text-base lg:leading-[26px]"; - - return ( -
- -
- - - - - - -
- - - {!recruitmentDateRange[0] || - (!recruitmentDateRange[1] &&

모집 기간은 필수입니다.

)} -
- - - { - setImageFiles(files); - }} - /> - {errors.imageUrls &&

{errors.imageUrls.message}

} - -
-
- ); -} diff --git a/src/app/(pages)/(addform)/addform/sections/WorkCondition.tsx b/src/app/(pages)/(addform)/addform/sections/WorkCondition.tsx deleted file mode 100644 index 5b0fccec..00000000 --- a/src/app/(pages)/(addform)/addform/sections/WorkCondition.tsx +++ /dev/null @@ -1,114 +0,0 @@ -"use client"; - -import { useForm, FormProvider } from "react-hook-form"; -import { WorkConditionFormData } from "@/types/addform"; -import { useEffect, useState } from "react"; -import Label from "../../component/Label"; -import { cn } from "@/lib/tailwindUtil"; -import DatePickerInput from "@/app/components/input/dateTimeDaypicker/DatePickerInput"; -import LocationInput from "@/app/components/input/text/LocationInput"; -import TimePickerInput from "@/app/components/input/dateTimeDaypicker/TimePickerInput"; -import DayPickerList from "@/app/components/input/dateTimeDaypicker/DayPickerList"; -import BaseInput from "@/app/components/input/text/BaseInput"; -import CheckBtn from "@/app/components/button/default/CheckBtn"; - -interface WorkConditionProps { - formData: WorkConditionFormData; - onUpdate: (data: WorkConditionFormData) => void; -} - -// 알바폼 만들기 - 사장님 - 3-근무조건 -export default function WorkCondition({ formData, onUpdate }: WorkConditionProps) { - const methods = useForm({ - mode: "onChange", - defaultValues: formData, - }); - - const { - register, - handleSubmit, - formState: { errors, isDirty }, - setValue, - watch, - } = methods; - - useEffect(() => { - if (formData) { - methods.reset(formData); - } - }, [formData, methods]); - useEffect(() => { - const subscription = watch((value) => { - if (isDirty) { - onUpdate(value as WorkConditionFormData); - } - }); - return () => subscription.unsubscribe(); - }, [watch, onUpdate, isDirty]); - const onSubmit = async (data: WorkConditionFormData) => { - onUpdate(data); - }; - - const [workDateRange, setWorkDateRange] = useState<[Date | null, Date | null]>([null, null]); - const handleWorkDateChange = (dates: [Date | null, Date | null]) => { - setWorkDateRange(dates); - const [start, end] = dates; - if (start) setValue("workStartDate", start.toISOString()); - if (end) setValue("workEndDate", end.toISOString()); - }; - - const errorTextStyle = - "absolute -bottom-[26px] right-1 text-[13px] text-sm font-medium leading-[22px] text-state-error lg:text-base lg:leading-[26px]"; - - return ( -
- -
- {/* 지도 API 연동 */} - - - -
- - - {!workDateRange[0] || - (!workDateRange[1] &&

모집 기간은 필수입니다.

)} -
- - -
- - -
- - - -
- -
- - - - - -
- -
- -
-
- ); -} diff --git a/src/app/(pages)/(addform)/layout.tsx b/src/app/(pages)/(addform)/layout.tsx deleted file mode 100644 index ea954748..00000000 --- a/src/app/(pages)/(addform)/layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -"use client"; -import AddFormLayout from "@/app/components/layout/addFormLayout/AddFormLayout"; -import { usePathname } from "next/navigation"; -import { ReactNode } from "react"; - -export default function Layout({ children }: { children: ReactNode }) { - const pathname = usePathname(); - const title = pathname.split("/").includes("apply") ? "알바폼 지원하기" : "알바폼 만들기"; - return {children}; -} diff --git a/src/app/(pages)/(addform)/component/Label.tsx b/src/app/(pages)/(albaform)/Label.tsx similarity index 100% rename from src/app/(pages)/(addform)/component/Label.tsx rename to src/app/(pages)/(albaform)/Label.tsx diff --git a/src/app/(pages)/(albaform)/addform/RecruitCondition.tsx b/src/app/(pages)/(albaform)/addform/RecruitCondition.tsx new file mode 100644 index 00000000..4678d3a2 --- /dev/null +++ b/src/app/(pages)/(albaform)/addform/RecruitCondition.tsx @@ -0,0 +1,53 @@ +"use client"; +import { useFormContext } from "react-hook-form"; +import Label from "../Label"; +import InputDropdown from "@/app/components/button/dropdown/InputDropdown"; + +// 알바폼 만들기 - 사장님- 2-모집조건 +export default function RecruitCondition() { + const { + register, + formState: { errors }, + } = useFormContext(); + + return ( +
+
+ + + + + + + + + + + + + + + +
+ ); +} diff --git a/src/app/(pages)/(albaform)/addform/RecruitContent.tsx b/src/app/(pages)/(albaform)/addform/RecruitContent.tsx new file mode 100644 index 00000000..9f02d4e0 --- /dev/null +++ b/src/app/(pages)/(albaform)/addform/RecruitContent.tsx @@ -0,0 +1,116 @@ +"use client"; +import Label from "../Label"; +import BaseInput from "@/app/components/input/text/BaseInput"; +import BaseTextArea from "@/app/components/input/textarea/BaseTextArea"; +import ImageInput from "@/app/components/input/file/ImageInput/ImageInput"; +import DatePickerInput from "@/app/components/input/dateTimeDaypicker/DatePickerInput"; +import { cn } from "@/lib/tailwindUtil"; +import { useFormContext } from "react-hook-form"; +import { useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; + +// 알바폼 만들기 - 사장님 - 1-모집내용 + +export default function RecruitContent() { + // 이미지 파일을 로컬 상태에 저장 + const [initialImageList, setInitialImageList] = useState<{ file: File; url: string; id: string }[]>([]); + + //훅폼 하위 컴포넌트에서는 useFormcontext에서 메서드 호출 + const { + register, + setValue, + getValues, + formState: { errors, isDirty }, + } = useFormContext(); + + const currentValue = getValues(); + + // 이미지 파일 change핸들러 + const handleChangeImages = (files: File[]) => { + // 훅폼 데이터에 추가-> 상위 페이지에서 "imageFiles" data를 관리할 수 있음 + setValue("imageFiles", files); + + // 기존 이미지 리스트와 새로운 이미지를 합침 + setInitialImageList((prevList) => [ + ...prevList, + ...files.map((file: File) => ({ + file, + url: URL.createObjectURL(file), + id: crypto.randomUUID(), + })), + ]); + }; + + const searchParams = useSearchParams(); + const currentParam = searchParams.get("tab"); + const initialLoad = currentParam === null; // 초기 로딩 여부 확인 + // 컴포넌트가 마운트될 때 이미지 초기값 설정 (초기로딩 제외) + useEffect(() => { + if (!initialLoad && currentValue.imageFiles?.length > 0) { + handleChangeImages(currentValue.imageFiles); + } + }, []); + + // 날짜 선택 + const [recruitmentDateRange, setRecruitmentDateRange] = useState<[Date | null, Date | null]>([null, null]); + const handleRecruitmentDateChange = (dates: [Date | null, Date | null]) => { + setRecruitmentDateRange(dates); + const [start, end] = dates; + if (start) setValue("recruitmentStartDate", start.toISOString()); + if (end) setValue("recruitmentEndDate", end.toISOString()); + }; + const errorTextStyle = + "absolute -bottom-[26px] right-1 text-[13px] text-sm font-medium leading-[22px] text-state-error lg:text-base lg:leading-[26px]"; + + return ( +
+
+ + + + + + +
+ + + {!recruitmentDateRange[0] || + (!recruitmentDateRange[1] &&

모집 기간은 필수입니다.

)} +
+ + + { + handleChangeImages(files); + }} + initialImageList={initialImageList || []} + /> + {errors.imageUrls &&

{errors.imageUrls.message as string}

} + +
+ ); +} diff --git a/src/app/(pages)/(albaform)/addform/WorkCondition.tsx b/src/app/(pages)/(albaform)/addform/WorkCondition.tsx new file mode 100644 index 00000000..ce3e957e --- /dev/null +++ b/src/app/(pages)/(albaform)/addform/WorkCondition.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useFormContext } from "react-hook-form"; +import { useState, ChangeEvent, MouseEvent, useEffect } from "react"; +import Label from "../Label"; +import { cn } from "@/lib/tailwindUtil"; +import DatePickerInput from "@/app/components/input/dateTimeDaypicker/DatePickerInput"; +import LocationInput from "@/app/components/input/text/LocationInput"; +import TimePickerInput from "@/app/components/input/dateTimeDaypicker/TimePickerInput"; +import DayPickerList from "@/app/components/input/dateTimeDaypicker/DayPickerList"; +import BaseInput from "@/app/components/input/text/BaseInput"; +import CheckBtn from "@/app/components/button/default/CheckBtn"; + +// 알바폼 만들기 - 사장님 - 3-근무조건 +export default function WorkCondition() { + const { + register, + setValue, + getValues, + trigger, + watch, + formState: { errors, isDirty }, + } = useFormContext(); + + // 근무 날짜 지정 + const [workDateRange, setWorkDateRange] = useState<[Date | null, Date | null]>([null, null]); + const handleWorkDateChange = (dates: [Date | null, Date | null]) => { + setWorkDateRange(dates); + const [start, end] = dates; + if (start) setValue("workStartDate", start.toISOString()); + if (end) setValue("workEndDate", end.toISOString()); + }; + + //근무 시간 지정 + const workStartTime = watch("workStartTime"); + const workEndTime = watch("workEndTime"); + + //근무 요일 지정 + const [selectedWorkDays, setSelectedWorkDays] = useState([]); + + const handleClickDay = (e: MouseEvent) => { + e.preventDefault(); + const day = e.currentTarget.textContent; + if (day) { + if (selectedWorkDays.includes(day)) { + // 이미 선택한 요일을 클릭했을때 + setSelectedWorkDays((prev) => prev.filter((d: string) => d !== day)); + } else { + setSelectedWorkDays([...selectedWorkDays, day]); + } + setValue("workDays", [...selectedWorkDays, day]); + trigger("workDays"); + } + }; + + // 시급 상태 추가 + const [displayWage, setDisplayWage] = useState(""); + + const formatNumber = (value: string) => { + let numericValue = value.replaceAll(/,/g, ""); + if (numericValue.startsWith("0")) { + numericValue = numericValue.slice(1); + } + const formatNumber = numericValue.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return formatNumber; + }; + + // 리액트 훅폼 데이터를 가져와서 렌더링 + useEffect(() => { + const selectedDays = getValues("workDays") || []; + setSelectedWorkDays(selectedDays); + const wage = getValues("hourlyWage") || 0; + setDisplayWage(wage); + }, [getValues]); + + const errorTextStyle = + "absolute -bottom-[26px] right-1 text-[13px] text-sm font-medium leading-[22px] text-state-error lg:text-base lg:leading-[26px]"; + + return ( +
+
+ {/* 지도 API 연동 */} + + + +
+ + + {!workDateRange[0] || + (!workDateRange[1] &&

근무 기간은 필수입니다.

)} +
+ + +
+ ) => { + setValue("workStartTime", e.target.value); + }} + /> + ) => { + setValue("workEndTime", e.target.value); + }} + /> + {!errors.workStartDate || + (!errors.workEndDate && !workStartTime && !workEndTime && isDirty && ( +

근무 시간을 선택해주세요.

+ ))} +
+ +
+ + + +
+ + {selectedWorkDays.length === 0 && isDirty && ( +

근무 요일을 선택해주세요.

+ )} +
+
+ + + 폼데이터에는 숫자형으로, 화면에는 세자리 콤마 추가 + onChange={(e: ChangeEvent) => { + const value = e.target.value; + const numericValue = Number(value.replace(/,/g, "")); + setValue("hourlyWage", numericValue); // 콤마 제거하고 숫자형으로 저장 + setDisplayWage(formatNumber(value)); + }} + variant="white" + afterString="원" + errormessage={errors.hourlyWage?.message as string} + /> + +
+ + +
+ +
+ ); +} diff --git a/src/app/(pages)/(albaform)/addform/page.tsx b/src/app/(pages)/(albaform)/addform/page.tsx new file mode 100644 index 00000000..24254bac --- /dev/null +++ b/src/app/(pages)/(albaform)/addform/page.tsx @@ -0,0 +1,281 @@ +"use client"; +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { FormProvider, useForm } from "react-hook-form"; +import axios from "axios"; +import TabMenuDropdown from "@/app/components/button/dropdown/TabMenuDropdown"; +import RecruitCondition from "./RecruitCondition"; +import Button from "@/app/components/button/default/Button"; +import { toast } from "react-hot-toast"; +import { useMutation } from "@tanstack/react-query"; +import { useUpdateProfile } from "@/hooks/queries/user/me/useUpdateProfile"; +import RecruitContent from "./RecruitContent"; +import WorkCondition from "./WorkCondition"; + +interface SubmitFormDataType { + isPublic: boolean; + hourlyWage: number; + isNegotiableWorkDays: boolean; + workDays: string[]; + workEndTime: string; + workStartTime: string; + workEndDate: string; + workStartDate: string; + location: string; + preferred: string; + age: string; + education: string; + gender: string; + numberOfPositions: number; + imageUrls: string[]; + recruitmentEndDate: string | undefined; + recruitmentStartDate: string | undefined; + description: string; + title: string; + imageFiles: File[]; +} +export default function AddFormPage() { + const router = useRouter(); + + // 리액트 훅폼에서 관리할 데이터 타입 지정 및 메서드 호출 (상위 컴포넌트 = useForm 사용) + const methods = useForm({ + mode: "onChange", + defaultValues: { + isPublic: false, + hourlyWage: 0, + isNegotiableWorkDays: false, + workDays: [], + workEndTime: "", + workStartTime: "", + workEndDate: "", + workStartDate: "", + location: "", + preferred: "", + age: "", + education: "", + gender: "", + numberOfPositions: 0, + recruitmentEndDate: undefined, + recruitmentStartDate: undefined, + description: "", + title: "", + imageUrls: [], + imageFiles: [], + }, + }); + + const { + setValue, + getValues, + handleSubmit, + formState: { isDirty, isValid }, + } = methods; + + // 훅폼에서 관리하는 전체 데이터를 가져오는 함수 + const currentValues: SubmitFormDataType = getValues(); + + // 이미지 업로드 api 처리를 위해 별도 변수에 할당 + const imageFiles = currentValues.imageFiles; + const [selectedOption, setSelectedOption] = useState("모집 내용"); + + // 폼 제출 리액트쿼리 + const mutation = useMutation({ + mutationFn: async () => { + const excludedKeys = ["displayDate", "workDateRange", "recruitDateRange", "imageFiles"]; + + // 원하는 필드만 포함된 새로운 객체 만들기 + const filteredData = Object.entries(currentValues) + .filter(([key]) => !excludedKeys.includes(key)) // 제외할 키를 필터링 + .reduce((acc: Partial, [key, value]) => { + if (key === "numberOfPositions") { + // numberOfPositions는 숫자형으로 변환 + acc[key] = Number(value); + } else if (key === "hourlyWage") { + // hourlyWage는 쉼표를 제거하고 숫자형으로 변환 + acc[key] = Number(value.replaceAll(/,/g, "")); // 쉼표 제거 후 숫자형 변환 + } else { + acc[key as keyof SubmitFormDataType] = value; // 나머지 값은 그대로 추가 + } + return acc; + }, {}); + await axios.post("/api/forms", filteredData, { + headers: { + "Content-Type": "application/json", + }, + }); + }, + onSuccess: () => { + if (typeof window !== "undefined") { + window.localStorage.removeItem("tempAddFormData"); + } + toast.success("알바폼을 등록했습니다."); + router.back(); // -> 추후 상세 페이지 이동으로 수정할것 + }, + onError: (error) => { + console.error("에러가 발생했습니다.", error); + toast.error("에러가 발생했습니다."); + onTempSave(); + }, + }); + + // tab 선택 시 Url params 수정 & 하위 폼 데이터 임시저장 + const searchParams = useSearchParams(); + const currentParam = searchParams.get("tab"); + const [prevOption, setPrevOption] = useState(null); + const initialLoad = currentParam === null; // 초기 로딩 여부 확인 + + const handleOptionChange = async (option: string) => { + setSelectedOption(option); + if (!initialLoad && option !== currentParam && option !== prevOption && isDirty) { + await onTempSave(); + setPrevOption(option); + } + const params = { + "모집 내용": "recruit-content", + "모집 조건": "recruit-condition", + "근무 조건": "work-condition", + }[option]; + router.replace(`/addform?tab=${params}`); + }; + + const renderChildren = () => { + switch (selectedOption) { + case "모집 내용": + return ; + case "모집 조건": + return ; + case "근무 조건": + return ; + default: + return <>; + } + }; + const { uploadImageMutation } = useUpdateProfile(); + + // 이미지 업로드 api + const uploadImages = async (files: File[]) => { + if (currentValues.imageUrls.length !== currentValues.imageFiles.length) { + const uploadedUrls: string[] = []; + + // 전체 파일 배열을 순회하면서 업로드 로직 진행 + for (const file of files) { + // 파일 크기 체크 + const maxSize = 5 * 1024 * 1024; // 5MB + if (file.size > maxSize) { + toast.error(`5MB 이상의 파일은 업로드할 수 없습니다.`); + continue; + } + const formData = new FormData(); + formData.append("image", file); + try { + const uploadResponse = await uploadImageMutation.mutateAsync(file); + if (uploadResponse?.url) { + uploadedUrls.push(uploadResponse.url); + } + } catch (uploadError) { + console.error(`파일 ${file.name} 업로드 실패:`, uploadError); + } + } + return uploadedUrls; + } else { + return currentValues.imageUrls; + } + }; + + // 폼데이터 임시 저장 함수 + const onTempSave = async () => { + // 이미지 처리 로직 + if (imageFiles && imageFiles.length > 0) { + try { + const uploadedUrls = await uploadImages(Array.from(imageFiles)); + if (uploadedUrls && uploadedUrls.length > 0) { + setValue("imageUrls", [...uploadedUrls]); + } else { + setValue("imageUrls", [...currentValues.imageUrls]); + } + } catch (error) { + console.error("임시저장 - 이미지 업로드 중 오류 발생:", error); + toast.error("이미지 업로드 중 오류가 발생했습니다."); + setValue("imageUrls", []); + } + } + // 임시저장 + if (typeof window !== "undefined") { + window.localStorage.setItem("tempAddFormData", JSON.stringify(currentValues)); + } + toast.success("임시 저장되었습니다."); + // console.log("임시저장 데이터", currentValues); + }; + + // 각각의 탭 작성중 여부 + const isEditingRecruitContent = + currentValues.title !== "" || + currentValues.description !== "" || + currentValues.recruitmentStartDate !== "" || + currentValues.imageUrls + ? true + : false; + const isEditingRecruitCondition = + currentValues.gender || + currentValues.numberOfPositions || + currentValues.education || + currentValues.age || + currentValues.preferred + ? true + : false; + const isEditingWorkCondition = + currentValues.location || + currentValues.workDays || + currentValues.workStartTime || + currentValues.workStartDate || + currentValues.hourlyWage || + currentValues.isNegotiableWorkDays || + currentValues.isPublic + ? true + : false; + + return ( + +
+ + {renderChildren()} +
+
+ ); +} diff --git a/src/app/(pages)/(addform)/apply/[formId]/page.tsx b/src/app/(pages)/(albaform)/apply/[formId]/page.tsx similarity index 86% rename from src/app/(pages)/(addform)/apply/[formId]/page.tsx rename to src/app/(pages)/(albaform)/apply/[formId]/page.tsx index 1242d693..07a6c47b 100644 --- a/src/app/(pages)/(addform)/apply/[formId]/page.tsx +++ b/src/app/(pages)/(albaform)/apply/[formId]/page.tsx @@ -8,7 +8,8 @@ import { cn } from "@/lib/tailwindUtil"; import axios from "axios"; import toast from "react-hot-toast"; import { useParams, useRouter } from "next/navigation"; -import Label from "../../component/Label"; +import Label from "@/app/(pages)/(albaform)/Label"; +import { useMutation } from "@tanstack/react-query"; interface ApplyFormData { name: string; phoneNumber: string; @@ -44,6 +45,8 @@ export default function Apply() { const formId = useParams().formId; const router = useRouter(); + const currentValues = getValues(); + const { resume, ...submitData } = currentValues; // 이력서 업로드 api -> id, name 반환 const uploadResume = async (file: FileList) => { @@ -60,7 +63,7 @@ export default function Apply() { }, timeout: 5000, // 5초 타임아웃 설정 }); - console.log("response", response); + console.log("이력서 업로드", response.data); return { resumeName: response.data.resumeName, resumeId: response.data.resumeId, @@ -72,39 +75,42 @@ export default function Apply() { return uploadedFile; }; - const onSubmit = async (data: ApplyFormData) => { - try { - const uploadedResume = await uploadResume(data.resume); - setValue("resumeId", uploadedResume.resumeId); - setValue("resumeName", uploadedResume.resumeName); + // 폼 제출 리액트쿼리 + const mutation = useMutation({ + mutationFn: async () => { + // 원하는 필드만 포함된 새로운 객체 만들기 + + await axios.post(`/api/forms/${formId}/applications`, submitData, { + headers: { + "Content-Type": "application/json", + }, + }); + }, - const { resume, ...submitData } = data; + onSuccess: () => { + if (typeof window !== "undefined") { + window.localStorage.removeItem("tempAddFormData"); + } + toast.success("알바폼을 등록했습니다."); + router.back(); // -> 추후 상세 페이지 이동으로 수정할것 + }, - await axios.post(`/api/forms/${formId}/applications`, submitData); - window.localStorage.removeItem("tempApplyData"); - toast.success("지원이 완료되었습니다."); - router.back(); - /** - * @Todo formId 페이지로 돌아가기 로 수정*/ - } catch (error) { - toast.error("에러가 발생했습니다. 작성 중인 내용은 임시 저장됩니다."); - console.error("Error submitting application:", error); + onError: (error) => { + console.error("에러가 발생했습니다.", error); + toast.error("에러가 발생했습니다."); onTempSave(); - } - }; + }, + }); const onTempSave = async () => { - const currentData = getValues(); try { - const uploadedResume = await uploadResume(currentData.resume); + const uploadedResume = await uploadResume(currentValues.resume); setValue("resumeId", uploadedResume.resumeId); setValue("resumeName", uploadedResume.resumeName); - const { resume, ...submitData } = currentData; - window.localStorage.setItem("tempApplyData", JSON.stringify(submitData)); + window.localStorage.setItem("tempApplyData", JSON.stringify(currentValues)); toast.success("임시 저장되었습니다."); - console.log("currentData", currentData); - console.log("submitData", submitData); + // console.log("currentData", currentValues); } catch (error) { console.error("Error uploading resume:", error); toast.error("이력서 업로드에 실패했습니다."); @@ -115,7 +121,7 @@ export default function Apply() { "absolute -bottom-[26px] right-1 text-[13px] text-sm font-medium leading-[22px] text-state-error lg:text-base lg:leading-[26px]"; return ( -
+ mutation.mutate())}> + Loading...}>{children} + + ); +} diff --git a/src/app/api/forms/[formId]/applications/route.ts b/src/app/api/forms/[formId]/applications/route.ts index 05a5d380..53c274aa 100644 --- a/src/app/api/forms/[formId]/applications/route.ts +++ b/src/app/api/forms/[formId]/applications/route.ts @@ -17,6 +17,7 @@ export async function POST(req: NextRequest, { params }: { params: { formId: str const response = await apiClient.post(`/forms/${params.formId}/applications`, body, { headers: { Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", }, }); diff --git a/src/app/api/forms/route.ts b/src/app/api/forms/route.ts index 7b67d189..a0d4b518 100644 --- a/src/app/api/forms/route.ts +++ b/src/app/api/forms/route.ts @@ -13,14 +13,15 @@ export async function POST(req: NextRequest) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); } - const formData = await req.formData(); + const body = await req.json(); - const response = await apiClient.post("/forms", formData, { + const response = await apiClient.post("/forms", body, { headers: { Authorization: `Bearer ${accessToken}`, - "Content-Type": "multipart/form-data", + "Content-Type": "application/json", }, }); + console.log(response.data); return NextResponse.json(response.data); } catch (error: unknown) { diff --git a/src/app/api/oauth/login/[provider]/route.ts b/src/app/api/oauth/login/[provider]/route.ts index 08bfc0db..59289963 100644 --- a/src/app/api/oauth/login/[provider]/route.ts +++ b/src/app/api/oauth/login/[provider]/route.ts @@ -1,5 +1,4 @@ import { AxiosError } from "axios"; -import { cookies } from "next/headers"; import { NextResponse } from "next/server"; import apiClient from "@/lib/apiClient"; diff --git a/src/app/components/button/default/CheckBtn.tsx b/src/app/components/button/default/CheckBtn.tsx index 1018cabc..5c35c9b3 100644 --- a/src/app/components/button/default/CheckBtn.tsx +++ b/src/app/components/button/default/CheckBtn.tsx @@ -1,40 +1,41 @@ "use client"; -import React, { InputHTMLAttributes } from "react"; +import React, { forwardRef, InputHTMLAttributes } from "react"; import { cn } from "@/lib/tailwindUtil"; interface CheckBtnProps extends InputHTMLAttributes { label: string; // 체크박스의 레이블 name: string; // 체크박스의 name 속성 - value: string; // 체크박스의 value 속성 checked?: boolean; // 체크박스가 선택된 상태인지 여부 disabled?: boolean; // 체크박스가 비활성화된 상태인지 여부 onChange?: (e: React.ChangeEvent) => void; + className?: string; } -const CheckBtn = ({ label, name, value, checked = false, disabled = false, onChange, ...props }: CheckBtnProps) => { - return ( -
- -
- ); -}; - + ); + } +); +CheckBtn.displayName = "CheckBtn"; export default CheckBtn; diff --git a/src/app/components/button/dropdown/InputDropdown.tsx b/src/app/components/button/dropdown/InputDropdown.tsx index 7a66d485..4aac5335 100644 --- a/src/app/components/button/dropdown/InputDropdown.tsx +++ b/src/app/components/button/dropdown/InputDropdown.tsx @@ -1,30 +1,45 @@ -import React, { forwardRef, useState } from "react"; +"use client"; +import React, { forwardRef, useEffect, useState } from "react"; import { IoMdArrowDropdown } from "react-icons/io"; import { cn } from "@/lib/tailwindUtil"; import DropdownList from "./dropdownComponent/DropdownList"; +import { useFormContext } from "react-hook-form"; interface InputDropdownProps { options: string[]; className?: string; errormessage?: string; + name: string; + value?: string; } const InputDropdown = forwardRef( - ({ options, className = "", errormessage }, ref) => { + ({ options, className = "", errormessage, name }, ref) => { const [isOpen, setIsOpen] = useState(false); const [selectedValue, setSelectedValue] = useState(""); const [isCustomInput, setIsCustomInput] = useState(false); + const { setValue, getValues } = useFormContext(); const handleOptionClick = (option: string) => { if (option === "직접 입력") { setIsCustomInput(true); setSelectedValue(""); + // 동적으로 받아온 name에 값 할당 -> 훅폼에 저장 + setValue(name, selectedValue); } else { setSelectedValue(option); setIsCustomInput(false); + setValue(name, option); setIsOpen(false); } }; + + // 작성중인 탭으로 다시 이동했을때 이전에 저장된 훅폼 데이터 연동 + useEffect(() => { + const data = getValues(); + setSelectedValue(data.name); + }, [getValues]); + const textStyle = "text-base"; const errorStyle = errormessage ? "!border-state-error" : ""; @@ -43,8 +58,14 @@ const InputDropdown = forwardRef( > isCustomInput && setSelectedValue(e.target.value)} + onChange={(e) => { + if (isCustomInput) { + setSelectedValue(e.target.value); + setValue(name, e.target.value); + } + }} className={cn( "text-grayscale-700 flex w-full items-center justify-between px-4 py-2 font-medium focus:outline-none", "cursor-pointer bg-transparent" diff --git a/src/app/components/button/dropdown/dropdownComponent/DropdownList.tsx b/src/app/components/button/dropdown/dropdownComponent/DropdownList.tsx index 001ac461..2ff46193 100644 --- a/src/app/components/button/dropdown/dropdownComponent/DropdownList.tsx +++ b/src/app/components/button/dropdown/dropdownComponent/DropdownList.tsx @@ -13,7 +13,6 @@ const DropdownItem = ({ }) => { return (
  • onSelect(item)} className={cn( "flex w-full cursor-pointer bg-grayscale-50 px-[10px] py-2 text-sm font-normal leading-[18px] text-black-100 hover:bg-primary-orange-50 lg:text-lg lg:leading-[26px]", diff --git a/src/app/components/input/applicantCountInput/ApplicantCountInput.tsx b/src/app/components/input/applicantCountInput/ApplicantCountInput.tsx index 9da681b7..41034adf 100644 --- a/src/app/components/input/applicantCountInput/ApplicantCountInput.tsx +++ b/src/app/components/input/applicantCountInput/ApplicantCountInput.tsx @@ -4,7 +4,7 @@ import BaseInput from "../text/BaseInput"; import { IoMdArrowDropdown, IoMdArrowDropup } from "react-icons/io"; const ApplicantCountInput = () => { - const { isOpen, handleOpenDropdown } = useDropdownOpen(); + const { isOpen } = useDropdownOpen(); const iconStyle = "text-black-400 size-9"; const arrowIcon = isOpen ? : ; return ( diff --git a/src/app/components/input/dateTimeDaypicker/DatePickerInput.tsx b/src/app/components/input/dateTimeDaypicker/DatePickerInput.tsx index a560d7bf..2971edd8 100644 --- a/src/app/components/input/dateTimeDaypicker/DatePickerInput.tsx +++ b/src/app/components/input/dateTimeDaypicker/DatePickerInput.tsx @@ -8,6 +8,7 @@ import { BsCalendar4 } from "react-icons/bs"; import { useDropdownOpen } from "@/hooks/useDropdownOpen"; import { useFormContext } from "react-hook-form"; import DatePickerHeader from "./DatePickerHeader"; +import { useEffect, useRef } from "react"; interface DatePickerInputProps { startDateName: string; endDateName: string; @@ -16,6 +17,7 @@ interface DatePickerInputProps { onChange: (dates: [Date | null, Date | null]) => void; required?: boolean; errormessage?: boolean; + displayValue: string; } const DatePickerInput = ({ startDateName, @@ -25,10 +27,18 @@ const DatePickerInput = ({ onChange, required, errormessage, + displayValue, }: DatePickerInputProps) => { const { setValue, watch } = useFormContext(); const { isOpen, handleOpenDropdown } = useDropdownOpen(); - const dateValue = watch("displayDate"); + const dateValue = watch(displayValue); + + useEffect(() => { + const currentValue = watch(displayValue); + if (currentValue) { + setValue(displayValue, currentValue); + } + }, [displayValue, watch, setValue]); const iconStyle = "text-black-400 size-9 transition-transform duration-200"; @@ -47,17 +57,32 @@ const DatePickerInput = ({ const [start, end] = update; if (start) { - setValue("displayDate", formatDisplayDate(start, end || undefined)); + setValue(displayValue, formatDisplayDate(start, end || undefined)); setValue(startDateName, start.toISOString()); } if (start && end && end > start) { - setValue("displayDate", formatDisplayDate(start, end)); + setValue(displayValue, formatDisplayDate(start, end)); setValue(endDateName, end.toISOString()); handleOpenDropdown(); } onChange(update); }; + //피커 바깥쪽 클릭 시 창 닫힘 + const pickerRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { + handleOpenDropdown(); + } + }; + window.addEventListener("mousedown", handleClickOutside); + return () => { + window.removeEventListener("mousedown", handleClickOutside); + }; + }, [handleOpenDropdown]); + return (
    @@ -78,6 +103,7 @@ const DatePickerInput = ({ <>
    { e.stopPropagation(); e.preventDefault(); diff --git a/src/app/components/input/dateTimeDaypicker/DayPickerList.tsx b/src/app/components/input/dateTimeDaypicker/DayPickerList.tsx index 5269f0f8..b4c976b3 100644 --- a/src/app/components/input/dateTimeDaypicker/DayPickerList.tsx +++ b/src/app/components/input/dateTimeDaypicker/DayPickerList.tsx @@ -1,26 +1,17 @@ -import { MouseEvent, useState } from "react"; +"use client"; +import { MouseEvent } from "react"; import DayPickerBtn from "./DayPickerBtn"; - -const DayPickerList = () => { - const [selectedDays, setSelectedDays] = useState([]); - - const days = ["일", "월", "화", "수", "목", "금", "토"]; - - const onClick = (e: MouseEvent) => { - const day = e.currentTarget.textContent; - if (day) { - if (selectedDays.includes(day)) { - setSelectedDays((prev) => prev.filter((d: string) => d !== day)); - } else { - setSelectedDays((prev) => [...prev, day]); // 눌렀을때 추가 - } - } - }; +interface DayPickerProps { + workDays: string[]; + onClick: (e: MouseEvent) => void; +} +const DayPickerList = ({ onClick, workDays }: DayPickerProps) => { + const daysToRender = ["일", "월", "화", "수", "목", "금", "토"]; return (
    - {days.map((day) => ( - + {daysToRender.map((day) => ( + ))}
    ); diff --git a/src/app/components/input/dateTimeDaypicker/TimePickerInput.tsx b/src/app/components/input/dateTimeDaypicker/TimePickerInput.tsx index bc1c0596..0ada1659 100644 --- a/src/app/components/input/dateTimeDaypicker/TimePickerInput.tsx +++ b/src/app/components/input/dateTimeDaypicker/TimePickerInput.tsx @@ -3,20 +3,25 @@ import { LuClock } from "react-icons/lu"; import { IoMdArrowDropup } from "react-icons/io"; import BaseInput from "../text/BaseInput"; import { useDropdownOpen } from "@/hooks/useDropdownOpen"; -import { useFormContext } from "react-hook-form"; import DropdownList from "../../button/dropdown/dropdownComponent/DropdownList"; +import { forwardRef } from "react"; +import { BaseInputProps } from "@/types/textInput"; + +const TimePickerInput = forwardRef((props, ref) => { + const { value, onChange, errormessage } = props; -const TimePickerInput = () => { - const { register, setValue, watch } = useFormContext(); - const timeValue = watch("timepicker"); const handleTimeSelect = (time: string) => { - setValue("timepicker", time); + if (onChange) { + onChange({ target: { value: time } } as React.ChangeEvent); + } handleOpenDropdown(); }; const { isOpen, handleOpenDropdown } = useDropdownOpen(); const beforeIconStyle = "text-grayscale-400 size-[13px] lg:size-5"; const afterIconStyle = "text-black-400 size-6 lg:size-9 transition-all transition-transform duration-200 ease-in-out"; + const width = "w-[150px] lg:w-[210px]"; + const timeOption = Array.from({ length: 24 }, (_, index) => { const hour = index.toString().padStart(2, "0"); return `${hour}:00`; @@ -26,25 +31,28 @@ const TimePickerInput = () => {
    {isOpen}
    } afterIcon={} - {...register("timepicker")} + errormessage={errormessage} /> {isOpen && ( )}
    ); -}; +}); +TimePickerInput.displayName = "TimePickerInput"; + export default TimePickerInput; diff --git a/src/app/components/input/file/ImageInput/ImageInput.tsx b/src/app/components/input/file/ImageInput/ImageInput.tsx index c585ad73..c80f7f60 100644 --- a/src/app/components/input/file/ImageInput/ImageInput.tsx +++ b/src/app/components/input/file/ImageInput/ImageInput.tsx @@ -13,10 +13,11 @@ interface ImageInputType { interface ImageInputProps { name: string; onChange?: (files: File[]) => void; + initialImageList: ImageInputType[]; } const ImageInput = forwardRef((props, ref) => { - const [imageList, setImageList] = useState([]); // 단순히 이미지 프리뷰를 위한 상태 관리 + const [imageList, setImageList] = useState(props.initialImageList || []); // 단순히 이미지 프리뷰를 위한 상태 관리 const handleFileChange = (selectedFile: File | null) => { if (selectedFile) { @@ -38,11 +39,10 @@ const ImageInput = forwardRef((props, ref) => ]; setImageList(newImageList); - props.onChange?.(newImageList.map((img) => img.file).filter((file) => file !== null)); } }; - const handleImageSelect = () => { + const handleOpenFileSelecter = () => { if (typeof ref === "function") { // input 요소를 찾아서 클릭 const fileInput = document.querySelector(`input[name="${props.name}"]`); @@ -69,18 +69,17 @@ const ImageInput = forwardRef((props, ref) => return ( // 인풋 + 프리뷰 wrapper -
    +
    ( ({ type = "text", variant, errormessage, feedbackMessage, ...props }, ref) => { return ( } diff --git a/src/app/components/layout/addFormLayout/AddFormLayout.tsx b/src/app/components/layout/addFormLayout/AddFormLayout.tsx index a48c192d..97bc6eb9 100644 --- a/src/app/components/layout/addFormLayout/AddFormLayout.tsx +++ b/src/app/components/layout/addFormLayout/AddFormLayout.tsx @@ -2,13 +2,21 @@ import { ReactNode } from "react"; import ApplyHeader from "./ApplyHeader"; import { useRouter } from "next/navigation"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/tailwindUtil"; -export default function AddFormLayout({ children, title }: { children: ReactNode; title: string }) { +export default function AddFormLayout({ children }: { children: ReactNode }) { const router = useRouter(); - + const pathname = usePathname(); + const title = pathname.split("/").includes("apply") ? "알바폼 지원하기" : "알바폼 만들기"; return ( <> -
    +
    router.back()} /> {children}
    diff --git a/src/app/stories/design-system/components/dropdown/InputDropdownBtn.stories.tsx b/src/app/stories/design-system/components/dropdown/InputDropdownBtn.stories.tsx index 77068b36..2d75a571 100644 --- a/src/app/stories/design-system/components/dropdown/InputDropdownBtn.stories.tsx +++ b/src/app/stories/design-system/components/dropdown/InputDropdownBtn.stories.tsx @@ -1,5 +1,7 @@ +import React from "react"; +import { Meta, StoryObj } from "@storybook/react"; +import { useForm, FormProvider } from "react-hook-form"; import InputDropdown from "@/app/components/button/dropdown/InputDropdown"; -import type { Meta, StoryObj } from "@storybook/react"; const meta = { title: "Design System/Components/Dropdown/InputDropdown", @@ -13,16 +15,37 @@ const meta = { export default meta; type Story = StoryObj; +const Template = (args: any) => { + const methods = useForm({ + defaultValues: { + [args.name]: "", // 기본 값 설정 + }, + }); + + return ( + + + + + + ); +}; + export const Default: Story = { + render: (args) =>