diff --git a/src/app/(pages)/(albaform)/addform/page.tsx b/src/app/(pages)/(albaform)/addform/page.tsx index e9269d06..5bc6da6b 100644 --- a/src/app/(pages)/(albaform)/addform/page.tsx +++ b/src/app/(pages)/(albaform)/addform/page.tsx @@ -15,7 +15,8 @@ import { SubmitFormDataType } from "@/types/addform"; import CustomFormModal from "@/app/components/modal/modals/confirm/CustomFormModal"; import tempSave from "@/utils/tempSave"; import DotLoadingSpinner from "@/app/components/loading-spinner/DotLoadingSpinner"; -import useUploadImages from "@/hooks/queries/user/me/useImageUpload"; +import { useUser } from "@/hooks/queries/user/me/useUser"; +import LoadingSpinner from "@/app/components/loading-spinner/LoadingSpinner"; export default function AddFormPage() { const router = useRouter(); @@ -43,7 +44,6 @@ export default function AddFormPage() { description: "", title: "", imageUrls: [], - imageFiles: [], }, }); @@ -57,12 +57,9 @@ export default function AddFormPage() { const currentValues: SubmitFormDataType = methods.watch(); // 이미지 업로드 api 처리를 위해 별도 변수에 할당 - const imageFiles = currentValues.imageFiles; const [, setSelectedOption] = useState(""); const [showTempDataModal, setShowTempDataModal] = useState(false); - const { uploadImages } = useUploadImages(); - // 각각의 탭 작성중 여부 const { isEditingRecruitContent, isEditingRecruitCondition, isEditingWorkCondition } = useEditing(currentValues); @@ -77,32 +74,8 @@ export default function AddFormPage() { const mutation = useMutation({ mutationFn: async () => { setLoading(true); - // 이미지 필수 체크 - if (!imageFiles || imageFiles.length === 0) { - toast.error("이미지를 첨부해주세요."); - throw new Error("이미지는 필수입니다."); - } - // 이미지 업로드 처리 - let uploadedUrls: string[] = []; - try { - if (currentValues.imageUrls.length !== currentValues.imageFiles.length) { - uploadedUrls = await uploadImages(Array.from(imageFiles)); - } else { - uploadedUrls = currentValues.imageUrls; - } - if (!uploadedUrls.length) { - toast.error("이미지 업로드에 실패했습니다."); - throw new Error("이미지 업로드 실패"); - } - setValue("imageUrls", uploadedUrls); - } catch (error) { - console.error("이미지 업로드 중 오류 발생:", error); - toast.error("이미지 업로드 중 오류가 발생했습니다."); - throw error; - } - - const excludedKeys = ["displayDate", "workDateRange", "recruitDateRange", "imageFiles"]; + const excludedKeys = ["displayDate", "workDateRange", "recruitDateRange"]; // 원하는 필드만 포함된 새로운 객체 만들기 const filteredData = Object.entries(currentValues) @@ -113,9 +86,6 @@ export default function AddFormPage() { } else if (key === "hourlyWage") { // 문자열이면 콤마 제거 후 숫자로 변환 acc[key] = typeof value === "string" ? String(Number(value.replace(/,/g, ""))) : String(Number(value)); - } else if (key === "imageUrls") { - // 업로드된 이미지 URL 사용 - acc[key] = uploadedUrls; } else { acc[key as keyof SubmitFormDataType] = value; } @@ -149,20 +119,6 @@ export default function AddFormPage() { // 폼데이터 임시 저장 함수 const onTempSave = async () => { - // 이미지 처리 로직 - if (currentValues.imageUrls.length !== currentValues.imageFiles.length) { - 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); - setValue("imageUrls", []); - } - } tempSave("addformData", currentValues); }; @@ -177,7 +133,7 @@ export default function AddFormPage() { "모집 조건": "recruit-condition", "근무 조건": "work-condition", }[option]; - router.push(`/addform?tab=${params}`); + router.replace(`/addform?tab=${params}`); }; useEffect(() => { @@ -265,6 +221,20 @@ export default function AddFormPage() { setShowTempDataModal(false); }; + // 유저 권한 확인 + const { user, isLoading } = useUser(); + + useEffect(() => { + if (user?.role !== "OWNER") { + toast.error("사장님만 알바폼을 작성할 수 있습니다."); + router.push("/alba-list"); + } + }, [user, router]); + + if (isLoading) { + return ; + } + return (
diff --git a/src/app/(pages)/(albaform)/addform/section/RecruitContentSection.tsx b/src/app/(pages)/(albaform)/addform/section/RecruitContentSection.tsx index 95d1ad02..2a8f5050 100644 --- a/src/app/(pages)/(albaform)/addform/section/RecruitContentSection.tsx +++ b/src/app/(pages)/(albaform)/addform/section/RecruitContentSection.tsx @@ -5,21 +5,78 @@ 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 { useState } from "react"; +import { useEffect, useState } from "react"; import Label from "../../component/Label"; +import { ImageInputType } from "@/types/addform"; +import useUploadImages from "@/hooks/queries/user/me/useImageUpload"; // 알바폼 만들기 - 사장님 - 1-모집내용 export default function RecruitContentSection() { // 이미지 파일을 로컬 상태에 저장 - const [initialImageList, setInitialImageList] = useState<{ file: File; url: string; id: string }[]>([]); + const [initialImageList, setInitialImageList] = useState([]); //훅폼 하위 컴포넌트에서는 useFormcontext에서 메서드 호출 const { + watch, register, setValue, formState: { errors }, } = useFormContext(); + const { uploadImages } = useUploadImages(); + + const imageUrlsData: string[] = watch("imageUrls"); + + // 이미지 파일 change핸들러 + const handleChangeImages = async (files: File[]) => { + let uploadedUrls: string[] = []; + //파일 선택 시 업로드 api 요청 + try { + uploadedUrls = await uploadImages(files); + console.log("이미지 파일 change 핸들러 - 이미지 업로드 성공"); + } catch (err) { + console.log("이미지 파일 체인지 핸들러 - 이미지 업로드 실패"); + console.error(err); + } + // 선택한 이미지 업데이트 + const updatedImageList = + uploadedUrls.map((url) => ({ + url, + id: crypto.randomUUID(), + })) || []; + + // 기존 이미지 포함하기 + const originalImageList = + imageUrlsData.map((url) => ({ + url, + id: crypto.randomUUID(), + })) || []; + + const allImageList = [...originalImageList, ...updatedImageList]; + const submitImageList = [...imageUrlsData, ...uploadedUrls]; + + // prop으로 전달 + setInitialImageList(allImageList); + // 훅폼 데이터에 세팅 + setValue("imageUrls", submitImageList, { shouldDirty: true }); + }; + + const handleDeleteImage = (url: string) => { + const newImageList = initialImageList.filter((item) => item.url !== url); + setInitialImageList(newImageList); + const urls = newImageList.map((item) => item.url); + setValue("imageUrls", urls, { shouldDirty: true }); + }; + // 초기 이미지 데이터 로딩 + useEffect(() => { + if (imageUrlsData?.length > 0) { + const originalUrls = imageUrlsData.map((url) => ({ + url, + id: crypto.randomUUID(), + })); + setInitialImageList(originalUrls); + } + }, [imageUrlsData]); // 날짜 선택 const [recruitmentDateRange, setRecruitmentDateRange] = useState<[Date | null, Date | null]>([null, null]); @@ -29,20 +86,6 @@ export default function RecruitContentSection() { if (start) setValue("recruitmentStartDate", start.toISOString()); if (end) setValue("recruitmentEndDate", end.toISOString()); }; - - // 이미지 파일 change핸들러 - const handleChangeImages = (files: File[]) => { - setValue("imageFiles", files); - - const newImages = files.map((file: File) => ({ - file, - url: URL.createObjectURL(file), - id: crypto.randomUUID(), - })); - - setInitialImageList(newImages); - }; - const errorTextStyle = "absolute -bottom-[26px] right-1 text-[13px] text-sm font-medium leading-[22px] text-state-error lg:text-base lg:leading-[26px]"; @@ -88,10 +131,11 @@ export default function RecruitContentSection() {
{ handleChangeImages(files); }} + onDelete={(id) => handleDeleteImage(id)} initialImageList={initialImageList} /> {errors.imageUrls &&

{errors.imageUrls.message as string}

} diff --git a/src/app/(pages)/(albaform)/alba/[formId]/components/FormDetail.tsx b/src/app/(pages)/(albaform)/alba/[formId]/components/FormDetail.tsx index a766e461..5b3d4e02 100644 --- a/src/app/(pages)/(albaform)/alba/[formId]/components/FormDetail.tsx +++ b/src/app/(pages)/(albaform)/alba/[formId]/components/FormDetail.tsx @@ -15,7 +15,9 @@ export default function FormDetails({ albaFormDetailData }: FormDetailsProps) { return ( <>
- {albaFormDetailData.storeName || "가게명"} + + {albaFormDetailData.storeName || "가게명"} + {albaFormDetailData.location || "위치"}

{albaFormDetailData.title}

@@ -25,7 +27,7 @@ export default function FormDetails({ albaFormDetailData }: FormDetailsProps) {

{albaFormDetailData.location}

-
diff --git a/src/app/(pages)/(albaform)/alba/[formId]/edit/page.tsx b/src/app/(pages)/(albaform)/alba/[formId]/edit/page.tsx index 8e319f06..b1938682 100644 --- a/src/app/(pages)/(albaform)/alba/[formId]/edit/page.tsx +++ b/src/app/(pages)/(albaform)/alba/[formId]/edit/page.tsx @@ -8,8 +8,7 @@ import axios from "axios"; import TabMenuDropdown from "@/app/components/button/dropdown/TabMenuDropdown"; 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 { useMutation, useQueryClient } from "@tanstack/react-query"; import RecruitContentSection from "../../../addform/section/RecruitContentSection"; import RecruitConditionSection from "../../../addform/section/RecruitConditionSection"; import WorkConditionSection from "../../../addform/section/WorkConditionSection"; @@ -19,6 +18,7 @@ import useFormDetail from "@/hooks/queries/form/detail/useFormDetail"; import LoadingSpinner from "@/app/components/loading-spinner/LoadingSpinner"; import formatMoney from "@/utils/formatMoney"; import tempSave from "@/utils/tempSave"; +import { useUser } from "@/hooks/queries/user/me/useUser"; export default function EditFormPage() { const router = useRouter(); @@ -40,18 +40,16 @@ export default function EditFormPage() { const { reset, - setValue, handleSubmit, - formState: { isDirty, isValid }, + formState: { isDirty }, } = methods; + const queryClient = useQueryClient(); + // 훅폼에서 관리하는 전체 데이터를 가져오는 함수 const currentValues: SubmitFormDataType = methods.watch(); - const imageFiles = currentValues.imageFiles; // 탭 선택 옵션 관리 const [selectedOption, setSelectedOption] = useState("모집 내용"); - // 이미지 업로드 훅 - const { uploadImageMutation } = useUpdateProfile(); // 각각의 탭 작성중 여부 const { isEditingRecruitContent, isEditingRecruitCondition, isEditingWorkCondition } = useEditing(currentValues); @@ -60,20 +58,20 @@ export default function EditFormPage() { if (albaFormDetailData) { reset({ isPublic: albaFormDetailData.isPublic, - hourlyWage: formatMoney(String(albaFormDetailData.hourlyWage)), // 쉼표 추가하기 + hourlyWage: formatMoney(String(albaFormDetailData.hourlyWage)), isNegotiableWorkDays: albaFormDetailData.isNegotiableWorkDays, workDays: albaFormDetailData.workDays, workEndTime: albaFormDetailData.workEndTime, workStartTime: albaFormDetailData.workStartTime, - workEndDate: albaFormDetailData.workEndDate, // display값에 반영하기 + workEndDate: albaFormDetailData.workEndDate, // display & value 값에 반영하기 workStartDate: albaFormDetailData.workStartDate, location: albaFormDetailData.location, - preferred: albaFormDetailData.preferred, //value 반영하기 - age: albaFormDetailData.age, //value 반영하기 - education: albaFormDetailData.education, //value 반영하기 - gender: albaFormDetailData.gender, //value 반영하기 - numberOfPositions: albaFormDetailData.numberOfPositions, //value 반영하기 - recruitmentEndDate: albaFormDetailData.recruitmentEndDate, // display값에 반영하기 + preferred: albaFormDetailData.preferred, + age: albaFormDetailData.age, + education: albaFormDetailData.education, + gender: albaFormDetailData.gender, + numberOfPositions: albaFormDetailData.numberOfPositions, + recruitmentEndDate: albaFormDetailData.recruitmentEndDate, // display & value 값에 반영하기 recruitmentStartDate: albaFormDetailData.recruitmentStartDate, description: albaFormDetailData.description, title: albaFormDetailData.title, @@ -82,59 +80,15 @@ export default function EditFormPage() { } }, [albaFormDetailData, reset]); - // 이미지 업로드 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); - setValue("imageUrls", []); - } - } tempSave("addformData", currentValues); }; // 수정된 폼 제출 리액트쿼리 const mutation = useMutation({ mutationFn: async () => { - const excludedKeys = ["displayDate", "workDateRange", "recruitDateRange", "imageFiles"]; + const excludedKeys = ["displayDate", "workDateRange", "recruitDateRange"]; // 원하는 필드만 포함된 새로운 객체 만들기 const filteredData = Object.entries(currentValues) @@ -152,6 +106,7 @@ export default function EditFormPage() { return acc; }, {}); await axios.patch(`/api/forms/${formId}`, filteredData); + console.log("filteredData", filteredData); }, onSuccess: () => { if (typeof window !== "undefined") { @@ -159,9 +114,14 @@ export default function EditFormPage() { } toast.success("알바폼을 수정했습니다."); router.push(`/alba/${formId}`); + // 쿼리 무효화 + queryClient.invalidateQueries({ + queryKey: ["formDetail", formId], + }); }, onError: (error) => { console.error("에러가 발생했습니다.", error); + console.log("currentValues", currentValues); toast.error("에러가 발생했습니다."); }, }); @@ -183,7 +143,7 @@ export default function EditFormPage() { "모집 조건": "recruit-condition", "근무 조건": "work-condition", }[option]; - router.push(`/alba/${formId}/edit?tab=${params}`); + router.replace(`/alba/${formId}/edit?tab=${params}`); }; const renderChildren = () => { @@ -199,12 +159,19 @@ export default function EditFormPage() { } }; - if (isLoading) - return ( -
- -
- ); + // 유저 권한 확인 + const { user } = useUser(); + + useEffect(() => { + if (user?.role !== "OWNER") { + toast.error("사장님만 알바폼을 작성할 수 있습니다."); + router.push("/alba-list"); + } + }, [user, router]); + + if (isLoading) { + return ; + } if (error) { return
Error: 데이터를 불러오는데 문제가 발생했습니다.
; @@ -226,14 +193,14 @@ export default function EditFormPage() { onChange={handleOptionChange} currentParam={currentParam || ""} /> -
+
diff --git a/src/app/(pages)/(albaform)/layout.tsx b/src/app/(pages)/(albaform)/layout.tsx index fc018f1f..d7f2987d 100644 --- a/src/app/(pages)/(albaform)/layout.tsx +++ b/src/app/(pages)/(albaform)/layout.tsx @@ -16,7 +16,7 @@ export default function Layout({ children }: { children: ReactNode }) { const isForm: boolean = pathname.split("/").some((path) => formPath.includes(path)); const isFormWithTab: boolean = pathname.split("/").some((path) => path === "addform" || path === "edit"); - const title = pathname.split("/").includes("apply") && isForm ? "알바폼 지원하기" : "알바폼 만들기"; + const title = pathname.split("/").includes("apply") && isForm ? "알바폼 지원하기" : "알바폼 작성하기"; // 폼 작성 페이지 레이아웃 const FormStyle = cn( diff --git a/src/app/components/button/dropdown/FilterDropdown.tsx b/src/app/components/button/dropdown/FilterDropdown.tsx index 38cc9780..348bbe57 100644 --- a/src/app/components/button/dropdown/FilterDropdown.tsx +++ b/src/app/components/button/dropdown/FilterDropdown.tsx @@ -76,7 +76,7 @@ const FilterDropdown = ({ options, className = "", onChange, initialValue, readO ? "border border-grayscale-100 bg-white" : "border-primary-orange-300 bg-primary-orange-50" )} - onClick={(e) => { + onMouseDown={(e) => { e.preventDefault(); toggleDropdown(); }} diff --git a/src/app/components/button/dropdown/TabMenuDropdown.tsx b/src/app/components/button/dropdown/TabMenuDropdown.tsx index 5e83df49..bddf5807 100644 --- a/src/app/components/button/dropdown/TabMenuDropdown.tsx +++ b/src/app/components/button/dropdown/TabMenuDropdown.tsx @@ -68,7 +68,7 @@ const TabMenuDropdown = ({ options, className = "", onChange, currentParam = "" key={option.label} onClick={() => handleOptionClick(option.label)} > - + {idx + 1} {option.label} void; + onDelete?: (id: string) => void; initialImageList: ImageInputType[]; } @@ -23,7 +20,7 @@ const ImageInput = forwardRef((props, ref) => if (props.initialImageList?.length > 0) { setImageList(props.initialImageList); } - }, [props.initialImageList]); + }, [props.initialImageList, imageList]); const handleFileChange = (selectedFile: File | null) => { if (selectedFile) { @@ -34,21 +31,18 @@ const ImageInput = forwardRef((props, ref) => toast.error("이미지는 최대 3개까지 업로드할 수 있습니다."); return; } - - const newImageList = [ - ...imageList, - { - file: selectedFile, - url: URL.createObjectURL(selectedFile), - id: crypto.randomUUID(), - }, - ]; - - setImageList(newImageList); - props.onChange?.(newImageList.map((img) => img.file).filter((file) => file !== null) as File[]); + props.onChange?.([selectedFile]); // 파일을 상위로 전달하면 url이 prop으로 내려옴 } }; + const handleDeleteImage = (targetUrl: string) => { + // 삭제 했을때 다른 이미지 미리보기도 엑박뜨는 현상 있음 ! + const newImageList = imageList.filter((image) => image.url !== targetUrl); + setImageList(newImageList); + // 제거한 리스트를 상위에서 setValue + props.onDelete?.(targetUrl); + }; + const handleOpenFileSelector = () => { if (typeof ref === "function") { // input 요소를 찾아서 클릭 @@ -61,16 +55,6 @@ const ImageInput = forwardRef((props, ref) => } }; - const handleDeleteImage = (targetId: string) => { - const targetImage = imageList.find((image) => image.id === targetId); - if (targetImage) { - URL.revokeObjectURL(targetImage.url); // URL 객체 해제 - } - const newImageList = imageList.filter((image) => image.id !== targetId); - setImageList(newImageList); - props.onChange?.(newImageList.map((img) => img.file).filter((file) => file !== null)); - }; - const colorStyle = { bgColor: "bg-background-200", borderColor: "border-[0.5px] border-transparent", diff --git a/src/app/components/input/file/ImageInput/PreviewItem.tsx b/src/app/components/input/file/ImageInput/PreviewItem.tsx index eb276c3f..26c76f4f 100644 --- a/src/app/components/input/file/ImageInput/PreviewItem.tsx +++ b/src/app/components/input/file/ImageInput/PreviewItem.tsx @@ -1,3 +1,4 @@ +import { ImageInputType } from "@/types/addform"; import Image from "next/image"; const PreviewItem = ({ @@ -5,18 +6,18 @@ const PreviewItem = ({ handleDeleteImage, placeholder, }: { - image: { url: string; id: string }; - handleDeleteImage: (id: string) => void; + image: ImageInputType; + handleDeleteImage: (url: string) => void; placeholder: boolean; }) => { - const { url, id } = image; + const { url } = image; const size = placeholder ? "size-[160px] lg:size-[240px]" : "size-20 lg:size-[116px]"; return (
{/* 이미지 미리보기 */} 미리보기
handleDeleteImage(id)} + onClick={() => handleDeleteImage(url)} className="absolute -right-2 -top-2 z-10 flex size-6 cursor-pointer items-center justify-center rounded-full lg:-right-3 lg:-top-3 lg:size-9" > 삭제 diff --git a/src/app/components/input/textarea/BaseTextArea.tsx b/src/app/components/input/textarea/BaseTextArea.tsx index cf9c9e43..0116112c 100644 --- a/src/app/components/input/textarea/BaseTextArea.tsx +++ b/src/app/components/input/textarea/BaseTextArea.tsx @@ -26,7 +26,7 @@ const BaseTextArea = forwardRef((props, hover: "hover:border-grayscale-300", }, }; - const defaultSize = "w-[327px] h-[132px] lg:w-[640px] lg:h-[160px] md:w-[480px] lg:w-full"; + const defaultSize = "w-[327px] h-[132px] lg:w-[640px] lg:h-[160px]"; const sizeStyles = props.size || defaultSize; // textareaStyle diff --git a/src/types/addform.d.ts b/src/types/addform.d.ts index d86941e6..cd9f2e29 100644 --- a/src/types/addform.d.ts +++ b/src/types/addform.d.ts @@ -18,5 +18,9 @@ export interface SubmitFormDataType { recruitmentStartDate: string | undefined; description: string; title: string; - imageFiles: File[]; +} + +export interface ImageInputType { + url: string; + id: string; }