diff --git a/src/app/(pages)/albaTalk/layout.tsx b/src/app/(pages)/albaTalk/layout.tsx new file mode 100644 index 00000000..0762119b --- /dev/null +++ b/src/app/(pages)/albaTalk/layout.tsx @@ -0,0 +1,17 @@ +import React, { Suspense } from "react"; + +export default function AlbaTalkLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
로딩 중...
+
+ } + > + {children} + + + ); +} diff --git a/src/app/(pages)/albaTalk/page.tsx b/src/app/(pages)/albaTalk/page.tsx index a421692d..3be2c6a5 100644 --- a/src/app/(pages)/albaTalk/page.tsx +++ b/src/app/(pages)/albaTalk/page.tsx @@ -1,5 +1,137 @@ "use client"; +import React, { useEffect } from "react"; +import { useInView } from "react-intersection-observer"; +import { usePosts } from "@/hooks/queries/post/usePosts"; +import { usePathname, useSearchParams } from "next/navigation"; +import SortSection from "@/app/components/layout/posts/SortSection"; +import SearchSection from "@/app/components/layout/posts/SearchSection"; +import { useUser } from "@/hooks/queries/user/me/useUser"; +import Link from "next/link"; +import { RiEdit2Fill } from "react-icons/ri"; +import FloatingBtn from "@/app/components/button/default/FloatingBtn"; +import CardBoard from "@/app/components/card/board/CardBoard"; + +const POSTS_PER_PAGE = 10; + export default function AlbaTalk() { - return
AlbaTalk
; + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { user } = useUser(); + + // URL 쿼리 파라미터에서 키워드와 정렬 기준 가져오기 + const keyword = searchParams.get("keyword"); + const orderBy = searchParams.get("orderBy"); + + // 무한 스크롤을 위한 Intersection Observer 설정 + const { ref, inView } = useInView({ + threshold: 0.1, + triggerOnce: false, + rootMargin: "100px", + }); + + // 게시글 목록 조회 + const { data, isLoading, error, hasNextPage, fetchNextPage, isFetchingNextPage } = usePosts({ + limit: POSTS_PER_PAGE, + keyword: keyword || undefined, + orderBy: orderBy || undefined, + }); + + // 스크롤이 하단에 도달하면 다음 페이지 로드 + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, fetchNextPage, isFetchingNextPage]); + + // 에러 상태 처리 + if (error) { + return ( +
+

게시글 목록을 불러오는데 실패했습니다.

+
+ ); + } + + // 로딩 상태 처리 + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ( +
+ {/* 검색 섹션과 정렬 옵션을 고정 위치로 설정 */} +
+ {/* 검색 섹션 */} +
+
+
+ +
+
+
+ + {/* 정렬 옵션 섹션 */} +
+
+
+ +
+
+
+
+ + {/* 메인 콘텐츠 영역 */} +
+ {/* 글쓰기 버튼 - 고정 위치 */} + {user && ( + + } variant="orange" /> + + )} + + {!data?.pages?.[0]?.data?.length ? ( +
+

등록된 게시글이 없습니다.

+
+ ) : ( +
+
+ {data?.pages.map((page) => ( + + {page.data.map((post) => ( +
+ + + +
+ ))} +
+ ))} +
+ + {/* 무한 스크롤 트리거 영역 */} +
+ {isFetchingNextPage && ( +
+
+
+ )} +
+
+ )} +
+
+ ); } diff --git a/src/app/(pages)/mypage/components/sections/CommentsSection.tsx b/src/app/(pages)/mypage/components/sections/CommentsSection.tsx index 8e16d2ae..669ed375 100644 --- a/src/app/(pages)/mypage/components/sections/CommentsSection.tsx +++ b/src/app/(pages)/mypage/components/sections/CommentsSection.tsx @@ -4,13 +4,17 @@ import React from "react"; import { useState } from "react"; import { useMyComments } from "@/hooks/queries/user/me/useMyComments"; import Pagination from "@/app/components/pagination/Pagination"; -import type { MyCommentType } from "@/types/response/user"; +import Comment from "@/app/components/card/board/Comment"; +import Link from "next/link"; import LoadingSpinner from "@/app/components/loading-spinner/LoadingSpinner"; +import { useUser } from "@/hooks/queries/user/me/useUser"; // 한 페이지당 댓글 수 const COMMENTS_PER_PAGE = 10; export default function CommentsSection() { + const { user } = useUser(); + // 현재 페이지 상태 관리 const [currentPage, setCurrentPage] = useState(1); @@ -57,18 +61,22 @@ export default function CommentsSection() { } return ( -
+
{/* 댓글 목록 렌더링 */} - {data.data.map((comment: MyCommentType) => ( -
-

{comment.post.title}

-

{comment.content}

-
- - {comment.updatedAt !== comment.createdAt && (수정됨)} +
+ {data.data.map((comment) => ( +
+ + console.log("케밥 메뉴 클릭", comment.id)} + /> +
-
- ))} + ))} +
{/* 페이지네이션 */} {totalPages > 1 && ( diff --git a/src/app/(pages)/mypage/components/sections/PostsSection.tsx b/src/app/(pages)/mypage/components/sections/PostsSection.tsx index 947c7fc6..7c723248 100644 --- a/src/app/(pages)/mypage/components/sections/PostsSection.tsx +++ b/src/app/(pages)/mypage/components/sections/PostsSection.tsx @@ -4,44 +4,21 @@ import React, { useEffect } from "react"; import { useInView } from "react-intersection-observer"; import { useMyPosts } from "@/hooks/queries/user/me/useMyPosts"; import { useMySortStore } from "@/store/mySortStore"; -import type { PostListType } from "@/types/response/post"; import { useProfileStringValue } from "@/hooks/queries/user/me/useProfileStringValue"; +import CardBoard from "@/app/components/card/board/CardBoard"; +import Link from "next/link"; import LoadingSpinner from "@/app/components/loading-spinner/LoadingSpinner"; // 한 페이지당 게시글 수 const POSTS_PER_PAGE = 10; -// 컴포넌트 +// 상태 메시지 컴포넌트 const StatusMessage = ({ message, className = "text-grayscale-500" }: { message: string; className?: string }) => (

{message}

); -const PostCard = ({ post }: { post: PostListType }) => ( -
-

{post.title}

-

{post.content}

-
- 댓글 {post.commentCount} - - 좋아요 {post.likeCount} -
-
-); - -const PostList = ({ pages }: { pages: any[] }) => ( - <> - {pages.map((page, index) => ( - - {page.data.map((post: PostListType) => ( - - ))} - - ))} - -); - export default function PostsSection() { // 정렬 상태 관리 const { orderBy } = useMySortStore(); @@ -49,9 +26,9 @@ export default function PostsSection() { // 무한 스크롤을 위한 Intersection Observer 설정 const { ref, inView } = useInView({ - threshold: 0.1, // 10% 정도 보이면 트리거 - triggerOnce: true, // 한 번만 트리거 (불필요한 API 호출 방지) - rootMargin: "100px", // 하단 100px 전에 미리 로드 + threshold: 0.1, + triggerOnce: false, + rootMargin: "100px", }); // 내가 작성한 게시글 목록 조회 @@ -74,8 +51,29 @@ export default function PostsSection() { if (!data?.pages[0]?.data?.length) return ; return ( -
- +
+ {/* 게시글 목록 렌더링 */} +
+ {data.pages.map((page) => ( + + {page.data.map((post) => ( +
+ + console.log("케밥 메뉴 클릭", post.id)} + /> + +
+ ))} +
+ ))} +
{/* 무한 스크롤 트리거 영역 */}
diff --git a/src/app/components/card/board/CardBoard.tsx b/src/app/components/card/board/CardBoard.tsx index 90a7bd98..54fc7b2d 100644 --- a/src/app/components/card/board/CardBoard.tsx +++ b/src/app/components/card/board/CardBoard.tsx @@ -2,14 +2,15 @@ import React, { useEffect, useState } from "react"; import Image from "next/image"; +import { formatLocalDate } from "@/utils/workDayFormatter"; export interface CardBoardProps { title: string; content: string; - userName: string; - date: string; - comments: number; - likes: number; + nickname: string; + updatedAt: Date; + commentCount: number; + likeCount: number; variant?: "default" | "primary"; onKebabClick?: () => void; // 케밥 버튼 클릭 핸들러 } @@ -17,16 +18,16 @@ export interface CardBoardProps { const CardBoard: React.FC = ({ title, content, - userName, - date, - comments, - likes, + nickname, + updatedAt, + commentCount, + likeCount, variant = "default", onKebabClick, }) => { const [isLargeScreen, setIsLargeScreen] = useState(false); const [isLiked, setIsLiked] = useState(false); - const [likeCount, setLikeCount] = useState(likes); + const [likeDisplayCount, setLikeDisplayCount] = useState(likeCount); useEffect(() => { const handleResize = () => { @@ -41,9 +42,9 @@ const CardBoard: React.FC = ({ const handleLikeClick = () => { if (isLiked) { - setLikeCount((prev) => prev - 1); // 좋아요 취소 시 감소 + setLikeDisplayCount((prev) => prev - 1); // 좋아요 취소 시 감소 } else { - setLikeCount((prev) => prev + 1); // 좋아요 클릭 시 증가 + setLikeDisplayCount((prev) => prev + 1); // 좋아요 클릭 시 증가 } setIsLiked((prev) => !prev); // 좋아요 상태 토글 }; @@ -55,10 +56,10 @@ const CardBoard: React.FC = ({
{/* Content Section */} -
+
{/* Header */}

{title}

@@ -78,15 +79,15 @@ const CardBoard: React.FC = ({
{/* Content */} -

+

{content}

{/* Footer Section */} -
+
{/* Left Info */} -
+
{/* 유저 아이콘 */} = ({ width={28} height={28} /> - {/* 이름 + 날짜 */} + {/* 닉네임 + 수정일 */}
- - {userName} + + {nickname} | - {date} + {formatLocalDate(updatedAt)}
{/* Right Info */} -
+
{/* 댓글 아이콘 */}
= ({ width={22} height={22} /> - {comments} + {commentCount}
{/* 좋아요 */}
@@ -136,7 +137,9 @@ const CardBoard: React.FC = ({ className="cursor-pointer" onClick={handleLikeClick} /> - {likeCount} + + {likeDisplayCount} +
diff --git a/src/app/components/card/board/Comment.tsx b/src/app/components/card/board/Comment.tsx index 60442562..a68aca9a 100644 --- a/src/app/components/card/board/Comment.tsx +++ b/src/app/components/card/board/Comment.tsx @@ -2,15 +2,16 @@ import React, { useEffect, useState } from "react"; import Image from "next/image"; +import { formatLocalDate } from "@/utils/workDayFormatter"; export interface CommentProps { - userName: string; - date: string; - comment: string; + nickname: string; + updatedAt: Date; + content: string; onKebabClick?: () => void; // 케밥 버튼 클릭 핸들러 } -const Comment: React.FC = ({ userName, date, comment, onKebabClick }) => { +const Comment: React.FC = ({ nickname, updatedAt, content, onKebabClick }) => { const [isLargeScreen, setIsLargeScreen] = useState(false); useEffect(() => { @@ -39,10 +40,12 @@ const Comment: React.FC = ({ userName, date, comment, onKebabClick />
- {userName} + {nickname} | - {date} + + {formatLocalDate(updatedAt)} +
@@ -58,7 +61,7 @@ const Comment: React.FC = ({ userName, date, comment, onKebabClick {/* Comment */}
-

{comment}

+

{content}

); diff --git a/src/app/components/card/cardList/AlbaListItem.tsx b/src/app/components/card/cardList/AlbaListItem.tsx index ccff7364..33a62e36 100644 --- a/src/app/components/card/cardList/AlbaListItem.tsx +++ b/src/app/components/card/cardList/AlbaListItem.tsx @@ -1,5 +1,5 @@ import Image from "next/image"; -import { formatRecruitDate } from "@/utils/workDayFormatter"; +import { formatLocalDate } from "@/utils/workDayFormatter"; import { getRecruitmentStatus, getRecruitmentDday } from "@/utils/recruitDateFormatter"; import { BsThreeDotsVertical } from "react-icons/bs"; import Chip from "@/app/components/chip/Chip"; @@ -177,7 +177,7 @@ const AlbaListItem = ({ - {formatRecruitDate(recruitmentStartDate, true)} ~ {formatRecruitDate(recruitmentEndDate, true)} + {formatLocalDate(recruitmentStartDate, true)} ~ {formatLocalDate(recruitmentEndDate, true)}
diff --git a/src/app/components/card/cardList/MyApplicationListItem.tsx b/src/app/components/card/cardList/MyApplicationListItem.tsx index 8980d10a..b6a63d0d 100644 --- a/src/app/components/card/cardList/MyApplicationListItem.tsx +++ b/src/app/components/card/cardList/MyApplicationListItem.tsx @@ -1,5 +1,5 @@ import { getRecruitmentStatus } from "@/utils/recruitDateFormatter"; -import { formatRecruitDate } from "@/utils/workDayFormatter"; +import { formatLocalDate } from "@/utils/workDayFormatter"; import Chip from "@/app/components/chip/Chip"; import Image from "next/image"; import { applicationStatus, ApplicationStatus } from "@/types/application"; @@ -77,7 +77,7 @@ const MyApplicationListItem = ({ id, createdAt, status, resumeId, resumeName, fo
지원일시 | - {formatRecruitDate(createdAt, true)} + {formatLocalDate(createdAt, true)}
diff --git a/src/app/components/layout/posts/SearchSection.tsx b/src/app/components/layout/posts/SearchSection.tsx new file mode 100644 index 00000000..8d18f71c --- /dev/null +++ b/src/app/components/layout/posts/SearchSection.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; +import SearchInput from "@/app/components/input/text/SearchInput"; + +export default function SearchSection() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [keyword, setKeyword] = useState(searchParams.get("keyword") || ""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const params = new URLSearchParams(searchParams); + + if (keyword.trim()) { + params.set("keyword", keyword); + } else { + params.delete("keyword"); + } + + router.push(`/albatalk?${params.toString()}`); + }; + + return ( +
+
+
+ ) => setKeyword(e.target.value)} + className="h-10 w-full bg-background-200 hover:bg-background-300" + /> +
+ +
+
+ ); +} diff --git a/src/app/components/layout/posts/SortSection.tsx b/src/app/components/layout/posts/SortSection.tsx new file mode 100644 index 00000000..3f71ee4b --- /dev/null +++ b/src/app/components/layout/posts/SortSection.tsx @@ -0,0 +1,42 @@ +"use client"; + +import React from "react"; +import FilterDropdown from "@/app/components/button/dropdown/FilterDropdown"; +import { postSortOptions } from "@/constants/postOptions"; +import { useRouter } from "next/navigation"; + +const SORT_OPTIONS = [ + { label: "최신순", value: postSortOptions.MOST_RECENT }, + { label: "댓글 많은순", value: postSortOptions.MOST_COMMENTED }, + { label: "좋아요 많은순", value: postSortOptions.MOST_LIKED }, +]; + +interface SortSectionProps { + pathname: string; + searchParams: URLSearchParams; +} + +export default function SortSection({ pathname, searchParams }: SortSectionProps) { + const router = useRouter(); + const currentOrderBy = searchParams.get("orderBy") || postSortOptions.MOST_RECENT; + + const currentLabel = SORT_OPTIONS.find((opt) => opt.value === currentOrderBy)?.label || SORT_OPTIONS[0].label; + + const handleSortChange = (selected: string) => { + const option = 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/stories/design-system/components/card/board/BoardComment.stories.tsx b/src/app/stories/design-system/components/card/board/BoardComment.stories.tsx new file mode 100644 index 00000000..6c191786 --- /dev/null +++ b/src/app/stories/design-system/components/card/board/BoardComment.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import BoardComment from "@/app/components/card/board/BoardComment"; + +const meta: Meta = { + title: "Design System/Components/Card/Board/BoardComment", + component: BoardComment, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "스터디카페 알바 지원합니다", + content: "안녕하세요. 스터디카페 알바에 지원하고 싶습니다. 주말 근무 가능하며, 성실하게 일하겠습니다.", + comments: "답변 대기중", + date: "2024.03.21", + variant: "default", + onKebabClick: () => console.log("케밥 메뉴 클릭"), + }, +}; + +export const Primary: Story = { + args: { + ...Default.args, + variant: "primary", + }, +}; + +export const LongContent: Story = { + args: { + ...Default.args, + title: "스터디카페 주말 알바 문의드립니다", + content: + "안녕하세요. 스터디카페 알바에 지원하고 싶어서 문의드립니다. 현재 대학생이며 주말에 일할 수 있는 알바를 찾고 있습니다. 근무 시간과 급여는 어떻게 되나요?", + comments: "답변 완료", + }, +}; diff --git a/src/app/stories/design-system/components/card/board/CardBoard.stories.tsx b/src/app/stories/design-system/components/card/board/CardBoard.stories.tsx new file mode 100644 index 00000000..f65f2caa --- /dev/null +++ b/src/app/stories/design-system/components/card/board/CardBoard.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import CardBoard from "@/app/components/card/board/CardBoard"; + +const meta: Meta = { + title: "Design System/Components/Card/Board/CardBoard", + component: CardBoard, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "스터디카페 주말 알바 구합니다", + content: "주말에 스터디카페에서 일하실 분을 구합니다. 시급은 협의 가능하며, 성실하신 분을 찾고 있습니다.", + nickname: "홍길동", + updatedAt: new Date("2024-03-21"), + commentCount: 5, + likeCount: 3, + variant: "default", + onKebabClick: () => console.log("케밥 메뉴 클릭"), + }, +}; + +export const Primary: Story = { + args: { + ...Default.args, + variant: "primary", + }, +}; + +export const LongContent: Story = { + args: { + ...Default.args, + title: "스터디카페에서 함께 일하실 분을 모집합니다 (주말/평일)", + content: + "안녕하세요. 강남역 근처 스터디카페에서 함께 일하실 분을 모집합니다. 주말 및 평일 근무 가능하신 분을 찾고 있으며, 근무 시간은 협의 가능합니다. 경력자 우대하며 초보자도 지원 가능합니다.", + }, +}; diff --git a/src/app/stories/design-system/components/card/board/Comment.stories.tsx b/src/app/stories/design-system/components/card/board/Comment.stories.tsx new file mode 100644 index 00000000..9757d5d4 --- /dev/null +++ b/src/app/stories/design-system/components/card/board/Comment.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import Comment from "@/app/components/card/board/Comment"; + +const meta: Meta = { + title: "Design System/Components/Card/Board/Comment", + component: Comment, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + nickname: "홍길동", + updatedAt: new Date("2024.03.21"), + content: "안녕하세요. 지원하고 싶어서 문의드립니다. 현재 근무 중이신 분들은 몇 분이나 계신가요?", + onKebabClick: () => console.log("케밥 메뉴 클릭"), + }, +}; + +export const LongComment: Story = { + args: { + nickname: "김철수", + updatedAt: new Date("2024.03.22"), + content: + "안녕하세요. 문의드립니다. 현재 근무 중이신 분들은 몇 분이나 계신가요? 그리고 주말 근무는 어떻게 되나요? 야간 근무도 있나요? 자세한 설명 부탁드립니다.", + onKebabClick: () => console.log("케밥 메뉴 클릭"), + }, +}; diff --git a/src/hooks/queries/post/usePosts.ts b/src/hooks/queries/post/usePosts.ts new file mode 100644 index 00000000..e3309b2c --- /dev/null +++ b/src/hooks/queries/post/usePosts.ts @@ -0,0 +1,47 @@ +import { PostListResponse } from "@/types/response/post"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import axios from "axios"; +import toast from "react-hot-toast"; + +interface UsePostsParams { + cursor?: string; + limit?: number; + orderBy?: string; + keyword?: string; +} + +export const usePosts = ({ cursor, limit = 10, orderBy, keyword }: UsePostsParams = {}) => { + const query = useInfiniteQuery({ + queryKey: ["posts", { limit, orderBy, keyword }], + queryFn: async () => { + try { + const response = await axios.get("/api/posts", { + params: { + cursor, + limit, + orderBy, + keyword, + }, + withCredentials: false, + }); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const errorMessage = error.response?.data?.message || "게시글 목록을 불러오는데 실패했습니다."; + toast.error(errorMessage); + } + throw error; + } + }, + getNextPageParam: (lastPage) => lastPage.nextCursor, + initialPageParam: undefined, + staleTime: 1000 * 60 * 5, // 5분 + gcTime: 1000 * 60 * 30, // 30분 + }); + + return { + ...query, + isPending: query.isPending, + error: query.error, + }; +}; diff --git a/src/utils/dateFormatter.ts b/src/utils/dateFormatter.ts deleted file mode 100644 index 0cb60d5a..00000000 --- a/src/utils/dateFormatter.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { workDayOptions } from "@/constants/workDayOptions"; - -export const formatRecruitDate = (date: Date, isMd: boolean = false) => { - const year = isMd ? date.getFullYear().toString() : date.getFullYear().toString().slice(2); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}.${month}.${day}`; -}; - -export const getWorkDaysDisplay = (isNegotiableWorkDays: boolean, workDays: string[] = []) => { - if (isNegotiableWorkDays) return "요일협의"; - if (!workDays.length) return "-"; - - // 요일 순서 정의 - const dayOrder = ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"]; - const sortedDays = workDays.sort((a, b) => dayOrder.indexOf(a) - dayOrder.indexOf(b)); - - const result: string[] = []; - let startDay = sortedDays[0]; - let prevIndex = dayOrder.indexOf(sortedDays[0]); - - for (let i = 1; i <= sortedDays.length; i++) { - const currentDay = sortedDays[i]; - const currentIndex = currentDay ? dayOrder.indexOf(currentDay) : -1; - - if (i === sortedDays.length || currentIndex !== prevIndex + 1) { - // 연속되지 않은 경우나 마지막 요일인 경우 - const endDay = sortedDays[i - 1]; - const startDayKor = workDayOptions[startDay as keyof typeof workDayOptions]; - const endDayKor = workDayOptions[endDay as keyof typeof workDayOptions]; - - if (startDay === endDay) { - result.push(startDayKor); - } else { - result.push(`${startDayKor}~${endDayKor}`); - } - startDay = currentDay; - } - prevIndex = currentIndex; - } - - return result.join(", "); -}; diff --git a/src/utils/workDayFormatter.ts b/src/utils/workDayFormatter.ts index dda4cd36..0de3774c 100644 --- a/src/utils/workDayFormatter.ts +++ b/src/utils/workDayFormatter.ts @@ -1,6 +1,6 @@ import { workDayOptions } from "@/constants/workDayOptions"; -export const formatRecruitDate = (date: Date, isMd: boolean = false) => { +export const formatLocalDate = (date: Date, isMd: boolean = false) => { // 유효한 Date 객체인지 확인 if (!(date instanceof Date) || isNaN(date.getTime())) { return new Date().toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });