diff --git a/package-lock.json b/package-lock.json index 214fc38d..cd10e0dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,7 @@ "@typescript-eslint/parser": "^8.9.0", "@vitejs/plugin-react": "^4.3.3", "autoprefixer": "^10.4.20", - "chromatic": "^11.18.1", + "chromatic": "^11.20.0", "dotenv-cli": "^7.4.3", "eslint": "^8.57.1", "eslint-config-next": "14.2.15", diff --git a/package.json b/package.json index 36b5251d..ae6f9875 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@typescript-eslint/parser": "^8.9.0", "@vitejs/plugin-react": "^4.3.3", "autoprefixer": "^10.4.20", - "chromatic": "^11.18.1", + "chromatic": "^11.20.0", "dotenv-cli": "^7.4.3", "eslint": "^8.57.1", "eslint-config-next": "14.2.15", diff --git a/src/app/(pages)/albaList/page.tsx b/src/app/(pages)/albaList/page.tsx index a6591cf8..721e35f0 100644 --- a/src/app/(pages)/albaList/page.tsx +++ b/src/app/(pages)/albaList/page.tsx @@ -13,6 +13,7 @@ import { useUser } from "@/hooks/queries/user/me/useUser"; import Link from "next/link"; import { IoAdd } from "react-icons/io5"; import { userRoles } from "@/constants/userRoles"; +import FloatingBtn from "@/app/components/button/default/FloatingBtn"; const FORMS_PER_PAGE = 10; @@ -130,15 +131,11 @@ export default function AlbaList() {
{/* 폼 만들기 버튼 - 고정 위치 */} {isOwner && ( -
- - - 폼 만들기 - -
+ + } variant="orange"> + 폼 만들기 + + )} {!data?.pages?.[0]?.data?.length ? ( @@ -152,11 +149,7 @@ export default function AlbaList() { {page.data.map((form) => (
- - - +
))}
diff --git a/src/app/(pages)/myAlbaform/(role)/applicant/components/ApplicantSortSection.tsx b/src/app/(pages)/myAlbaform/(role)/applicant/components/ApplicantSortSection.tsx new file mode 100644 index 00000000..68f7eb67 --- /dev/null +++ b/src/app/(pages)/myAlbaform/(role)/applicant/components/ApplicantSortSection.tsx @@ -0,0 +1,45 @@ +"use client"; + +import React from "react"; +import FilterDropdown from "@/app/components/button/dropdown/FilterDropdown"; +import { formStatusOptions } from "@/constants/formOptions"; +import { useRouter } from "next/navigation"; + +const APPLICANT_SORT_OPTIONS = [ + { label: "전체", value: "" }, + { label: "최신순", value: formStatusOptions.INTERVIEW_PENDING }, + { label: "시급높은순", value: formStatusOptions.INTERVIEW_COMPLETED }, + { label: "지원자 많은순", value: formStatusOptions.HIRED }, + { label: "스크랩 많은순", value: formStatusOptions.REJECTED }, +]; + +interface ApplicantSortSectionProps { + pathname: string; + searchParams: URLSearchParams; +} + +export default function ApplicantSortSection({ pathname, searchParams }: ApplicantSortSectionProps) { + const router = useRouter(); + const currentOrderBy = searchParams.get("orderBy") || ""; + + const currentLabel = + APPLICANT_SORT_OPTIONS.find((opt) => opt.value === currentOrderBy)?.label || APPLICANT_SORT_OPTIONS[0].label; + + const handleSortChange = (selected: string) => { + const option = APPLICANT_SORT_OPTIONS.find((opt) => opt.label === selected); + if (option) { + const params = new URLSearchParams(searchParams); + params.set("orderBy", option.value); + router.push(`${pathname}?${params.toString()}`); + } + }; + + return ( + option.label)} + className="!w-28 md:!w-40" + initialValue={currentLabel} + onChange={handleSortChange} + /> + ); +} diff --git a/src/app/(pages)/myAlbaform/(role)/applicant/page.tsx b/src/app/(pages)/myAlbaform/(role)/applicant/page.tsx index cbff9fa0..d4867c6e 100644 --- a/src/app/(pages)/myAlbaform/(role)/applicant/page.tsx +++ b/src/app/(pages)/myAlbaform/(role)/applicant/page.tsx @@ -1,25 +1,76 @@ "use client"; +import React from "react"; import { useEffect } from "react"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useInView } from "react-intersection-observer"; import { useUser } from "@/hooks/queries/user/me/useUser"; import { userRoles } from "@/constants/userRoles"; +import ApplicantSortSection from "./components/ApplicantSortSection"; +import SearchSection from "@/app/components/layout/forms/SearchSection"; +import MyApplicationListItem from "@/app/components/card/cardList/MyApplicationListItem"; +import { useMyApplications } from "@/hooks/queries/user/me/useMyApplications"; + +const APPLICATIONS_PER_PAGE = 10; export default function ApplicantPage() { const router = useRouter(); - const { user, isLoading } = useUser(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { user, isLoading: isUserLoading } = useUser(); + + // 무한 스크롤을 위한 Intersection Observer 설정 + const { ref, inView } = useInView({ + threshold: 0.1, + triggerOnce: false, + rootMargin: "100px", + }); + + // 검색 및 정렬 상태 관리 + const status = searchParams.get("status") || undefined; + const keyword = searchParams.get("keyword") || undefined; + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading: isLoadingData, + error, + } = useMyApplications({ + limit: APPLICATIONS_PER_PAGE, + status, + keyword, + }); useEffect(() => { - if (!isLoading) { + if (!isUserLoading) { if (!user) { router.push("/login"); } else if (user.role === userRoles.OWNER) { router.push("/myAlbaform/owner"); } } - }, [user, isLoading, router]); + }, [user, isUserLoading, router]); + + // 스크롤이 하단에 도달하면 다음 페이지 로드 + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + // 에러 상태 처리 + if (error) { + return ( +
+

지원 내역을 불러오는데 실패했습니다.

+
+ ); + } - if (isLoading) { + // 로딩 상태 처리 + if (isUserLoading || isLoadingData) { return (
로딩 중...
@@ -27,11 +78,64 @@ export default function ApplicantPage() { ); } - // 지원자용 페이지 컨텐츠 return ( -
-

지원자 페이지

- {/* 지원자용 컨텐츠 */} +
+ {/* 검색 섹션과 필터를 고정 위치로 설정 */} +
+ {/* 검색 섹션 */} +
+
+ +
+
+ + {/* 필터 섹션 */} +
+
+ +
+
+
+ + {/* 메인 콘텐츠 영역 */} +
+ {!data?.pages?.[0]?.data?.length ? ( +
+

지원 내역이 없습니다.

+
+ ) : ( +
+
+ {data?.pages.map((page) => ( + + {page.data.map((application) => ( +
+ +
+ ))} +
+ ))} +
+ + {/* 무한 스크롤 트리거 영역 */} +
+ {isFetchingNextPage && ( +
+
+
+ )} +
+
+ )} +
); } diff --git a/src/app/(pages)/myAlbaform/(role)/owner/page.tsx b/src/app/(pages)/myAlbaform/(role)/owner/page.tsx index 842ac803..189572c1 100644 --- a/src/app/(pages)/myAlbaform/(role)/owner/page.tsx +++ b/src/app/(pages)/myAlbaform/(role)/owner/page.tsx @@ -13,6 +13,7 @@ import { useUser } from "@/hooks/queries/user/me/useUser"; import Link from "next/link"; import { IoAdd } from "react-icons/io5"; import { userRoles } from "@/constants/userRoles"; +import FloatingBtn from "@/app/components/button/default/FloatingBtn"; const FORMS_PER_PAGE = 10; @@ -186,15 +187,11 @@ export default function AlbaList() {
{/* 폼 만들기 버튼 - 고정 위치 */} {isOwner && ( -
- - - 폼 만들기 - -
+ + } variant="orange"> + 폼 만들기 + + )} {!data?.pages?.[0]?.data?.length ? ( diff --git a/src/app/components/card/cardList/AlbaListItem.tsx b/src/app/components/card/cardList/AlbaListItem.tsx index 2db50c33..3e3c6445 100644 --- a/src/app/components/card/cardList/AlbaListItem.tsx +++ b/src/app/components/card/cardList/AlbaListItem.tsx @@ -62,11 +62,26 @@ const AlbaListItem = ({ openModal("customForm", { isOpen: true, title: "지원하기", - content: "정말로 지원하시겠습니까?", + content: "지원하시겠습니까?", onConfirm: () => { + openModal("customForm", { + isOpen: false, + title: "", + content: "", + onConfirm: () => {}, + onCancel: () => {}, + }); router.push(`/apply/${id}`); }, - onCancel: () => {}, + onCancel: () => { + openModal("customForm", { + isOpen: false, + title: "", + content: "", + onConfirm: () => {}, + onCancel: () => {}, + }); + }, }); }; @@ -76,11 +91,26 @@ const AlbaListItem = ({ openModal("customForm", { isOpen: true, title: "스크랩 확인", - content: "이 공고를 스크랩하시겠습니까?", + content: "스크랩하시겠습니까?", onConfirm: () => { + openModal("customForm", { + isOpen: false, + title: "", + content: "", + onConfirm: () => {}, + onCancel: () => {}, + }); scrap(); }, - onCancel: () => {}, + onCancel: () => { + openModal("customForm", { + isOpen: false, + title: "", + content: "", + onConfirm: () => {}, + onCancel: () => {}, + }); + }, }); }; diff --git a/src/app/components/card/cardList/MyApplicationListItem.tsx b/src/app/components/card/cardList/MyApplicationListItem.tsx index 353b298e..8980d10a 100644 --- a/src/app/components/card/cardList/MyApplicationListItem.tsx +++ b/src/app/components/card/cardList/MyApplicationListItem.tsx @@ -3,9 +3,10 @@ import { formatRecruitDate } from "@/utils/workDayFormatter"; import Chip from "@/app/components/chip/Chip"; import Image from "next/image"; import { applicationStatus, ApplicationStatus } from "@/types/application"; -import { ApplicationListItemProps } from "@/types/response/application"; import axios from "axios"; import toast from "react-hot-toast"; +import { MyApplicationType } from "@/types/response/user"; +import { MdOutlineImage } from "react-icons/md"; // 지원 상태에 따른 Chip 컴포넌트의 variant를 반환하는 함수 const getStatusVariant = (status: ApplicationStatus) => { @@ -22,6 +23,8 @@ const getStatusVariant = (status: ApplicationStatus) => { // 지원 상태에 따른 한글 라벨을 반환하는 함수 const getStatusLabel = (status: ApplicationStatus) => { switch (status) { + case applicationStatus.ALL: + return "전체"; case applicationStatus.HIRED: return "채용 완료"; case applicationStatus.REJECTED: @@ -35,15 +38,12 @@ const getStatusLabel = (status: ApplicationStatus) => { } }; -const MyApplicationListItem = ({ createdAt, status, resumeId, resumeName, form }: ApplicationListItemProps) => { - // 현재 공고의 모집 상태를 가져옴 - const recruitmentStatus = getRecruitmentStatus(new Date(form.recruitmentEndDate)); - +const MyApplicationListItem = ({ id, createdAt, status, resumeId, resumeName, form }: MyApplicationType) => { // 이력서 다운로드 핸들러 const handleResumeDownload = async () => { try { // API를 통해 이력서 파일을 다운로드 - const response = await axios.get(`/api/resumes/${resumeId}`, { + const response = await axios.get(`/api/resume/${resumeId}/download`, { responseType: "blob", }); @@ -70,7 +70,7 @@ const MyApplicationListItem = ({ createdAt, status, resumeId, resumeName, form } }; return ( -
+
{/* 상단 영역: 지원일시와 이력서 링크 */}
@@ -92,7 +92,13 @@ const MyApplicationListItem = ({ createdAt, status, resumeId, resumeName, form } {/* 가게 프로필 이미지와 이름 */}
- {form.owner.storeName} + {form.owner.imageUrl ? ( + {form.owner.storeName} + ) : ( +
+ +
+ )}
{form.owner.storeName}
@@ -105,12 +111,20 @@ const MyApplicationListItem = ({ createdAt, status, resumeId, resumeName, form }
{/* 하단 상태 표시 영역: 지원 상태와 모집 상태 */} -
-
- +
+
+
-
- +
+
diff --git a/src/app/components/card/cardList/ScrapListItem.tsx b/src/app/components/card/cardList/ScrapListItem.tsx index 4eea5b64..8503636a 100644 --- a/src/app/components/card/cardList/ScrapListItem.tsx +++ b/src/app/components/card/cardList/ScrapListItem.tsx @@ -61,7 +61,7 @@ const ScrapListItem = ({ openModal("customForm", { isOpen: true, title: "지원하기", - content: "정말로 지원하시겠습니까?", + content: "지원하시겠습니까?", onConfirm: () => { router.push(`/apply/${id}`); }, @@ -75,7 +75,7 @@ const ScrapListItem = ({ openModal("customForm", { isOpen: true, title: "스크랩 취소 확인", - content: "정말로 스크랩을 취소하시겠습니까?", + content: "스크랩을 취소하시겠습니까?", onConfirm: () => { unscrap(); }, diff --git a/src/app/stories/design-system/components/card/cardList/MyApplicationListItem.stories.tsx b/src/app/stories/design-system/components/card/cardList/MyApplicationListItem.stories.tsx index baa2e216..f02e727e 100644 --- a/src/app/stories/design-system/components/card/cardList/MyApplicationListItem.stories.tsx +++ b/src/app/stories/design-system/components/card/cardList/MyApplicationListItem.stories.tsx @@ -26,8 +26,8 @@ const mockProps = { storeName: "스타벅스 강남점", id: 1, }, - recruitmentEndDate: "2024-12-31T00:00:00.000Z", - recruitmentStartDate: "2024-06-01T00:00:00.000Z", + recruitmentEndDate: new Date("2024-12-31T00:00:00.000Z"), + recruitmentStartDate: new Date("2024-06-01T00:00:00.000Z"), description: "스타벅스 강남점에서 함께할 파트타이머를 모집합니다. 주말 근무 가능자 우대, 경력자 우대, 장기 근무자 우대", title: "스타벅스 강남점 파트타이머 모집", @@ -80,7 +80,7 @@ export const RecruitmentClosed: Story = { ...mockProps, form: { ...mockProps.form, - recruitmentEndDate: "2024-01-01T00:00:00.000Z", + recruitmentEndDate: new Date("2024-01-01T00:00:00.000Z"), }, }, }; diff --git a/src/app/stories/design-system/pages/albaList/page.tsx b/src/app/stories/design-system/pages/albaList/page.tsx index afe9cb28..d44f937f 100644 --- a/src/app/stories/design-system/pages/albaList/page.tsx +++ b/src/app/stories/design-system/pages/albaList/page.tsx @@ -12,6 +12,7 @@ import StorySearchSection from "@/app/stories/design-system/components/layout/Se import Header from "@/app/stories/design-system/components/layout/Header"; import Link from "next/link"; import { IoAdd } from "react-icons/io5"; +import FloatingBtn from "@/app/components/button/default/FloatingBtn"; interface AlbaListProps { mockData?: FormListType[][]; @@ -107,8 +108,9 @@ const AlbaList: React.FC = () => { href="/addform" className="flex items-center gap-2 rounded-lg bg-[#FFB800] px-4 py-3 text-base font-semibold text-white shadow-lg transition-all hover:bg-[#FFA800] md:px-6 md:text-lg" > - - 폼 만들기 + } variant="orange"> + 폼 만들기 +
diff --git a/src/types/application.ts b/src/types/application.ts index b88f462d..e32662d3 100644 --- a/src/types/application.ts +++ b/src/types/application.ts @@ -1,4 +1,5 @@ export const applicationStatus = { + ALL: "", REJECTED: "REJECTED", INTERVIEW_PENDING: "INTERVIEW_PENDING", INTERVIEW_COMPLETED: "INTERVIEW_COMPLETED", diff --git a/src/types/response/application.d.ts b/src/types/response/application.d.ts index e3a33c96..f2686cdf 100644 --- a/src/types/response/application.d.ts +++ b/src/types/response/application.d.ts @@ -18,25 +18,3 @@ export interface ApplicationListResponse { data: Array; nextCursor: number | null; } - -// 지원 목록 아이템 Props -export interface ApplicationListItemProps { - id: number; - updatedAt: Date; - createdAt: Date; - status: ApplicationStatus; - resumeName: string; - resumeId: number; - form: { - id: number; - title: string; - description: string; - recruitmentStartDate: string; - recruitmentEndDate: string; - owner: { - id: number; - storeName: string; - imageUrl: string; - }; - }; -}