diff --git a/.pnp.cjs b/.pnp.cjs index 273e4c1c..4d54b87e 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -125,6 +125,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["tailwindcss", "npm:3.4.8"],\ ["tailwindcss-animate", "virtual:d9cd1cf96fc105240ce4126e416dd90faeefaf08cea474f2ffdd0a21bc6194bc993779567e0c0bf19c40cbbd585d546a065d8582fcc0925f8826e5fbca78aa72#npm:1.0.7"],\ ["tippy.js", "npm:6.3.7"],\ + ["ts-pattern", "npm:5.3.1"],\ ["typescript", "patch:typescript@npm%3A5.1.6#~builtin::version=5.1.6&hash=5da071"],\ ["webpack", "virtual:d9cd1cf96fc105240ce4126e416dd90faeefaf08cea474f2ffdd0a21bc6194bc993779567e0c0bf19c40cbbd585d546a065d8582fcc0925f8826e5fbca78aa72#npm:5.93.0"]\ ],\ @@ -9066,6 +9067,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["tailwindcss", "npm:3.4.8"],\ ["tailwindcss-animate", "virtual:d9cd1cf96fc105240ce4126e416dd90faeefaf08cea474f2ffdd0a21bc6194bc993779567e0c0bf19c40cbbd585d546a065d8582fcc0925f8826e5fbca78aa72#npm:1.0.7"],\ ["tippy.js", "npm:6.3.7"],\ + ["ts-pattern", "npm:5.3.1"],\ ["typescript", "patch:typescript@npm%3A5.1.6#~builtin::version=5.1.6&hash=5da071"],\ ["webpack", "virtual:d9cd1cf96fc105240ce4126e416dd90faeefaf08cea474f2ffdd0a21bc6194bc993779567e0c0bf19c40cbbd585d546a065d8582fcc0925f8826e5fbca78aa72#npm:5.93.0"]\ ],\ @@ -18185,6 +18187,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["ts-pattern", [\ + ["npm:5.3.1", {\ + "packageLocation": "./.yarn/cache/ts-pattern-npm-5.3.1-5f66f2def4-e9d59c9139.zip/node_modules/ts-pattern/",\ + "packageDependencies": [\ + ["ts-pattern", "npm:5.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["ts-pnp", [\ ["npm:1.2.0", {\ "packageLocation": "./.yarn/cache/ts-pnp-npm-1.2.0-43620de7df-c2a698b85d.zip/node_modules/ts-pnp/",\ diff --git a/.yarn/cache/ts-pattern-npm-5.3.1-5f66f2def4-e9d59c9139.zip b/.yarn/cache/ts-pattern-npm-5.3.1-5f66f2def4-e9d59c9139.zip new file mode 100644 index 00000000..3cbaf16e Binary files /dev/null and b/.yarn/cache/ts-pattern-npm-5.3.1-5f66f2def4-e9d59c9139.zip differ diff --git a/package.json b/package.json index 02b2fac2..1153108b 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,8 @@ "react-dom": "^18", "tailwind-merge": "^2.4.0", "tailwind-variants": "^0.2.1", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "ts-pattern": "^5.3.1" }, "devDependencies": { "@chromatic-com/storybook": "^1.6.1", diff --git a/src/app/(sidebar)/my-recruit/api/useDeleteRecruit.ts b/src/app/(sidebar)/my-recruit/api/useDeleteRecruit.ts new file mode 100644 index 00000000..a90cb16d --- /dev/null +++ b/src/app/(sidebar)/my-recruit/api/useDeleteRecruit.ts @@ -0,0 +1,25 @@ +import { http } from '@/apis/http'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { GET_PROGRESSING_RECRUITS_KEY } from '@/app/(sidebar)/my-recruit/api/useGetProgressingRecruits'; +import { GET_ALL_RECRUITS_KEY } from '@/app/(sidebar)/my-recruit/api/useGetAllRecruits'; + +export const DELETE_RECRUIT_KEY = 'delete-recruit'; + +export const deleteRecruit = (recruitId: number) => + http.delete({ + url: `/recruits/${recruitId}`, + }); + +export const useDeleteRecruit = () => { + const queryClient = useQueryClient(); + + const mutate = useMutation({ + mutationFn: (recruitId: number) => deleteRecruit(recruitId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [GET_PROGRESSING_RECRUITS_KEY] }); + queryClient.invalidateQueries({ queryKey: [GET_ALL_RECRUITS_KEY] }); + }, + }); + + return mutate; +}; diff --git a/src/app/(sidebar)/my-recruit/api/useGetAllRecruits.ts b/src/app/(sidebar)/my-recruit/api/useGetAllRecruits.ts new file mode 100644 index 00000000..34d1f522 --- /dev/null +++ b/src/app/(sidebar)/my-recruit/api/useGetAllRecruits.ts @@ -0,0 +1,27 @@ +import { http } from '@/apis/http'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { RecruitCard } from '@/app/(sidebar)/my-recruit/type'; +import { ALL_RECRUITMENT } from '@/app/(sidebar)/my-recruit/containers/components/SeasonDropdownContent'; + +type Request = { season: string }; + +type Response = { data: RecruitCard[] }; + +export const GET_ALL_RECRUITS_KEY = 'recruits-all'; + +function getAllRecruits() { + return http.get({ url: '/recruits' }); +} + +function getRecruitsBySeason({ season }: Request) { + return http.get({ url: '/recruits/bySeason', params: { season } }); +} + +export function useGetAllRecruits({ season }: Request) { + const result = useSuspenseQuery({ + queryKey: [GET_ALL_RECRUITS_KEY, season], + queryFn: season === ALL_RECRUITMENT ? getAllRecruits : () => getRecruitsBySeason({ season }), + }); + + return result.data as unknown as Response; +} diff --git a/src/app/(sidebar)/my-recruit/api/useGetCards.ts b/src/app/(sidebar)/my-recruit/api/useGetCards.ts new file mode 100644 index 00000000..317403d6 --- /dev/null +++ b/src/app/(sidebar)/my-recruit/api/useGetCards.ts @@ -0,0 +1,28 @@ +import { http } from '@/apis/http'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { TagType } from '@/types/info'; + +interface Props { + type: string; +} + +interface Response { + data: Array<{ + id: number; + title: string; + updatedDate: string; + tagList: Array; + }>; +} + +export const GET_CARDS = 'cards'; + +function getCards({ type }: Props) { + return http.get({ url: '/cards', params: { type } }); +} + +export function useGetCards({ type }: Props) { + const result = useSuspenseQuery({ queryKey: [GET_CARDS, type], queryFn: () => getCards({ type }) }); + + return result.data as unknown as Response; +} diff --git a/src/app/(sidebar)/my-recruit/api/useGetProgressingRecruits.ts b/src/app/(sidebar)/my-recruit/api/useGetProgressingRecruits.ts new file mode 100644 index 00000000..3eec9b3e --- /dev/null +++ b/src/app/(sidebar)/my-recruit/api/useGetProgressingRecruits.ts @@ -0,0 +1,20 @@ +import { http } from '@/apis/http'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { RecruitCard } from '@/app/(sidebar)/my-recruit/type'; + +type Response = { data: RecruitCard[] }; + +export const GET_PROGRESSING_RECRUITS_KEY = 'recruits-progressing'; + +function getProgressingRecruits() { + return http.get({ url: '/recruits/progressing' }); +} + +export function useGetProgressingRecruits() { + const result = useSuspenseQuery({ + queryKey: [GET_PROGRESSING_RECRUITS_KEY], + queryFn: getProgressingRecruits, + }); + + return result.data as unknown as Response; +} diff --git a/src/app/(sidebar)/my-recruit/api/useGetSeasons.ts b/src/app/(sidebar)/my-recruit/api/useGetSeasons.ts new file mode 100644 index 00000000..1ede2dcd --- /dev/null +++ b/src/app/(sidebar)/my-recruit/api/useGetSeasons.ts @@ -0,0 +1,20 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { http } from '@/apis/http'; + +type Response = { + data: Array<{ + name: string; + }>; +}; + +export const GET_SEASONS_KEY = 'seasons'; + +function getSeasons() { + return http.get({ url: '/seasons' }); +} + +export function useGetSeasons() { + const result = useSuspenseQuery({ queryKey: [GET_SEASONS_KEY], queryFn: getSeasons }); + + return result.data as unknown as Response; +} diff --git a/src/app/(sidebar)/my-recruit/api/usePatchRecruitStatus.ts b/src/app/(sidebar)/my-recruit/api/usePatchRecruitStatus.ts new file mode 100644 index 00000000..ab85752d --- /dev/null +++ b/src/app/(sidebar)/my-recruit/api/usePatchRecruitStatus.ts @@ -0,0 +1,29 @@ +import { http } from '@/apis/http'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { GET_PROGRESSING_RECRUITS_KEY } from '@/app/(sidebar)/my-recruit/api/useGetProgressingRecruits'; +import { GET_ALL_RECRUITS_KEY } from '@/app/(sidebar)/my-recruit/api/useGetAllRecruits'; + +interface Request { + id: number; + recruitStatus: string; +} + +export const PATCH_RECRUIT_STATUS_KEY = 'put-recruit-status'; + +function patchRecruitStatus({ id, recruitStatus }: Request) { + return http.patch({ url: `/recruits/${id}/status`, data: { recruitStatus } }); +} + +export function usePatchRecruitStatus() { + const queryClient = useQueryClient(); + + const mutate = useMutation({ + mutationFn: (data: Request) => patchRecruitStatus(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [GET_PROGRESSING_RECRUITS_KEY] }); + queryClient.invalidateQueries({ queryKey: [GET_ALL_RECRUITS_KEY] }); + }, + }); + + return mutate; +} diff --git a/src/app/(sidebar)/my-recruit/api/usePostCardToRecruit.ts b/src/app/(sidebar)/my-recruit/api/usePostCardToRecruit.ts new file mode 100644 index 00000000..6c4fe2e2 --- /dev/null +++ b/src/app/(sidebar)/my-recruit/api/usePostCardToRecruit.ts @@ -0,0 +1,22 @@ +import { http } from '@/apis/http'; +import { useMutation } from '@tanstack/react-query'; + +interface Request { + recruitId: number; + cardId: number; +} + +export const POST_CARD_TO_RECRUIT_KEY = 'post-card-to-recruit'; + +function postCardToRecruit({ recruitId, cardId }: Request) { + return http.post({ url: `/recruits/${recruitId}/cards/${cardId}` }); +} + +export function usePostCardToRecruit() { + const mutate = useMutation({ + mutationKey: [POST_CARD_TO_RECRUIT_KEY], + mutationFn: (data: Request) => postCardToRecruit(data), + }); + + return mutate; +} diff --git a/src/app/(sidebar)/my-recruit/api/usePostRecruit.ts b/src/app/(sidebar)/my-recruit/api/usePostRecruit.ts new file mode 100644 index 00000000..21817706 --- /dev/null +++ b/src/app/(sidebar)/my-recruit/api/usePostRecruit.ts @@ -0,0 +1,32 @@ +import { http } from '@/apis/http'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { GET_PROGRESSING_RECRUITS_KEY } from '@/app/(sidebar)/my-recruit/api/useGetProgressingRecruits'; +import { GET_ALL_RECRUITS_KEY } from '@/app/(sidebar)/my-recruit/api/useGetAllRecruits'; + +export interface Request { + title: string; + season: string; + siteUrl: string; + recruitScheduleStage: string; + deadline: string | null; +} + +export const POST_RECRUIT_KEY = 'post-recruit'; + +function postRecruit(data: Request) { + return http.post({ url: '/recruits', data }); +} + +export function usePostRecruit() { + const queryClient = useQueryClient(); + + const mutate = useMutation({ + mutationFn: (data: Request) => postRecruit(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [GET_PROGRESSING_RECRUITS_KEY] }); + queryClient.invalidateQueries({ queryKey: [GET_ALL_RECRUITS_KEY] }); + }, + }); + + return mutate; +} diff --git a/src/app/(sidebar)/my-recruit/components/NewRecruitDialogContent/InputField.tsx b/src/app/(sidebar)/my-recruit/components/NewRecruitDialogContent/InputField.tsx index 841e0d43..239fd67d 100644 --- a/src/app/(sidebar)/my-recruit/components/NewRecruitDialogContent/InputField.tsx +++ b/src/app/(sidebar)/my-recruit/components/NewRecruitDialogContent/InputField.tsx @@ -9,7 +9,7 @@ interface Props extends ComponentProps<'input'> { export function InputField({ required = false, right, value, ...inputProps }: Props) { return ( -
+
*
diff --git a/src/app/(sidebar)/my-recruit/components/NewRecruitDialogContent/NewRecruitDialogContent.tsx b/src/app/(sidebar)/my-recruit/components/NewRecruitDialogContent/NewRecruitDialogContent.tsx index a0f08d35..9a23350b 100644 --- a/src/app/(sidebar)/my-recruit/components/NewRecruitDialogContent/NewRecruitDialogContent.tsx +++ b/src/app/(sidebar)/my-recruit/components/NewRecruitDialogContent/NewRecruitDialogContent.tsx @@ -3,39 +3,50 @@ import { TouchButton } from '@/components/TouchButton'; import { Dialog } from '@/system/components/Dialog/ShadcnDialog'; import { color } from '@/system/token/color'; import { InputField } from './InputField'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Icon } from '@/system/components'; -import { getCurrentYearAndHalf, getNextYearAndHalf } from './date'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/system/components/DropdownMenu/DropdownMenu'; +import { Dropdown } from '@/system/components'; import clsx from 'clsx'; import { motion } from 'framer-motion'; import { Popover, PopoverContent, PopoverTrigger } from '@/system/components/Popover/Popover'; import { Calendar } from '@/system/components/Calendar/Calendar'; import { format } from 'date-fns/format'; -import { If } from '@/system/utils/If'; +import { useGetSeasons } from '../../api/useGetSeasons'; +import { recruitStatusList } from '../../constant'; +import { recruitScheduleStageList } from '@/app/(sidebar)/my-recruit/constant'; -interface Props { - onSubmit: () => void; +export interface CardData { + season: string; + title: string; + siteUrl: string; + recruitScheduleStage: string; + deadline: string | null; +} + +interface NewRecruitDialogContentProps { + onSubmit: (data: CardData) => void; } const TITLE_MAX_LENGTH = 30; -// FIXME: 서버쪽에서 전달해주는 데이터로 교체 -const DEFAULT_DROPDOWN_PERIOD = [getCurrentYearAndHalf(), getNextYearAndHalf()]; -export function NewRecruitDialogContent() { +export function NewRecruitDialogContent({ onSubmit }: NewRecruitDialogContentProps) { const [title, setTitle] = useState(''); - const [selectedPeriod, setSelectedPeriod] = useState(getCurrentYearAndHalf()); - const [link, setLink] = useState(''); + const [siteUrl, setSiteUrl] = useState(''); const [selectedDate, setSelectedDate] = useState(); + const [currentRecruitStage, setCurrentRecruitStage] = useState(recruitStatusList[3].text); - const isButtonActivated = title.length !== 0; + const [selectedSeason, setSelectedSeason] = useState(); + const seasonList = useGetSeasons()?.data ?? []; const isDateSelected = selectedDate != null; + useEffect(() => { + if (selectedSeason == null) { + setSelectedSeason(seasonList[0]?.name); + } + }, [seasonList.length]); + + const canSubmit = title.length !== 0 && selectedSeason != null; + return (
@@ -57,35 +68,48 @@ export function NewRecruitDialogContent() { {/* 지원 시기 입력 */} - - - - - - {/* TODO: 이전 공고들의 연도들도 추가 */} - {[...DEFAULT_DROPDOWN_PERIOD].reverse().map((period) => ( - setSelectedPeriod(period)}> - - {period} - - - - - + + + {seasonList.reverse().map((season) => ( + setSelectedSeason(season.name)}> + {season.name} + ))} - - + + {/* 마감일 입력 */} -
- 서류마감 +
+ + +
+ {currentRecruitStage} + +
+
+ + {recruitScheduleStageList.map((item, index) => ( + setCurrentRecruitStage(item)}> + {item} + + ))} + +
- + - {isDateSelected ? format(selectedDate, 'yyyy.mm.dd') : '마감일을 선택해주세요'} + {isDateSelected ? format(selectedDate, 'yyyy.MM.dd') : '마감일을 선택해주세요'} @@ -113,30 +137,37 @@ export function NewRecruitDialogContent() { {/* 공고 링크 입력 */} } - onChange={(event) => setLink(event.target.value)} + right={} + onChange={(event) => setSiteUrl(event.target.value)} /> {/* 제출 버튼 */} - - 공고 추가하기 - + + { + if (canSubmit) { + onSubmit({ + title, + siteUrl, + season: selectedSeason, + recruitScheduleStage: currentRecruitStage, + deadline: selectedDate != null ? format(selectedDate, 'yyyy-MM-dd') : null, + }); + } + }} + className="w-full flex justify-center items-center h-48 rounded-[6px]"> + 공고 추가하기 + +
); } diff --git a/src/app/(sidebar)/my-recruit/components/NewRecruitDialogContent/date.ts b/src/app/(sidebar)/my-recruit/components/NewRecruitDialogContent/date.ts deleted file mode 100644 index 37783bc9..00000000 --- a/src/app/(sidebar)/my-recruit/components/NewRecruitDialogContent/date.ts +++ /dev/null @@ -1,26 +0,0 @@ -// NOTE: 서버에서 넘어오는 값에 따라 변경될 예정입니다. -export function getCurrentYearAndHalf() { - const now = new Date(); - const year = now.getFullYear(); - const month = now.getMonth() + 1; - - const half = month <= 6 ? '상반기' : '하반기'; - - return `${year}년 ${half}`; -} - -export function getNextYearAndHalf() { - const now = new Date(); - let year = now.getFullYear(); - const month = now.getMonth() + 1; - - let half: string; - if (month <= 6) { - half = '하반기'; - } else { - half = '상반기'; - year += 1; - } - - return `${year}년 ${half}`; -} diff --git a/src/app/(sidebar)/my-recruit/constant.ts b/src/app/(sidebar)/my-recruit/constant.ts new file mode 100644 index 00000000..991c08d7 --- /dev/null +++ b/src/app/(sidebar)/my-recruit/constant.ts @@ -0,0 +1,24 @@ +export const recruitScheduleStageList = ['서류 마감', '1차 면접', '2차 면접', '3차 면접', '최종 면접']; + +export const recruitStatusList = [ + { variant: 'text', text: '지원 준비' }, + { variant: 'text', text: '지원 완료' }, + { variant: 'border' }, + { variant: 'text', text: '서류 마감' }, + { variant: 'text', text: '서류 탈락' }, + { variant: 'border' }, + { variant: 'text', text: '면접 통과' }, + { variant: 'text', text: '면접 탈락' }, + { variant: 'border' }, + { variant: 'text', text: '최종 합격' }, + { variant: 'text', text: '최종 탈락' }, +] as const; + +export const INFO_CATEGORIES = [ + '경험_정리', + '자기소개서', + '면접_질문', + '서류_준비', + '과제_준비', + '인터뷰_준비', +] as const; diff --git a/src/app/(sidebar)/my-recruit/containers/AllRecruitment.tsx b/src/app/(sidebar)/my-recruit/containers/AllRecruitment.tsx deleted file mode 100644 index 34927397..00000000 --- a/src/app/(sidebar)/my-recruit/containers/AllRecruitment.tsx +++ /dev/null @@ -1,61 +0,0 @@ -'use client'; - -import { Icon } from '@/system/components'; -import { RocketIcon } from './components/RocketIcon'; -import { Spacing } from '@/system/utils/Spacing'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/system/components/DropdownMenu/DropdownMenu'; -import { motion } from 'framer-motion'; -import { color } from '@/system/token/color'; -import { Dialog } from '@/system/components/Dialog/ShadcnDialog'; -import { cardList } from '../mock'; -import { RowCard } from './components/Card/RowCard'; -import { Droppable, useDndContext } from '@/lib/dnd-kit/dnd-kit'; - -export function AllRecruitment() { - const { over } = useDndContext(); - - return ( - <> - - - - - 모든 공고 - - - - - - - - 등록된 공고가 없어요 - - - -
- {cardList.map((cardInfo) => ( - - - - ))} -
- - ); -} diff --git a/src/app/(sidebar)/my-recruit/containers/AllRecruitment/AllRecruitList.tsx b/src/app/(sidebar)/my-recruit/containers/AllRecruitment/AllRecruitList.tsx new file mode 100644 index 00000000..a7da8f9e --- /dev/null +++ b/src/app/(sidebar)/my-recruit/containers/AllRecruitment/AllRecruitList.tsx @@ -0,0 +1,58 @@ +import { RowCard } from '@/app/(sidebar)/my-recruit/containers/components/Card/RowCard'; +import { Droppable, useDndContext } from '@/lib/dnd-kit/dnd-kit'; +import { motion } from 'framer-motion'; +import { color } from '@/system/token/color'; +import { Dialog } from '@/system/components/Dialog/ShadcnDialog'; +import { useGetAllRecruits } from '../../api/useGetAllRecruits'; +import { SwitchCase } from '@/system/utils/SwitchCase'; +import { usePatchRecruitStatus } from '@/app/(sidebar)/my-recruit/api/usePatchRecruitStatus'; +import { useDeleteRecruit } from '@/app/(sidebar)/my-recruit/api/useDeleteRecruit'; + +interface Props { + selectedSeason: string; +} + +export function AllRecruitList({ selectedSeason }: Props) { + const { over } = useDndContext(); + const allRecruits = useGetAllRecruits({ season: selectedSeason }).data; + + const { mutate: patchRecruitStatus } = usePatchRecruitStatus(); + const { mutate: deleteRecruit } = useDeleteRecruit(); + + return ( + <> + + + 등록된 공고가 없어요 + + + ), + present: ( +
+ {allRecruits.map((cardInfo) => ( + + { + patchRecruitStatus({ id, recruitStatus: status }); + }} + /> + + ))} +
+ ), + }} + /> + + ); +} diff --git a/src/app/(sidebar)/my-recruit/containers/AllRecruitment/AllRecruitment.tsx b/src/app/(sidebar)/my-recruit/containers/AllRecruitment/AllRecruitment.tsx new file mode 100644 index 00000000..cce676b9 --- /dev/null +++ b/src/app/(sidebar)/my-recruit/containers/AllRecruitment/AllRecruitment.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { Dropdown, Icon } from '@/system/components'; +import { RocketIcon } from '../components/RocketIcon'; +import { Spacing } from '@/system/utils/Spacing'; +import { AllRecruitList } from '@/app/(sidebar)/my-recruit/containers/AllRecruitment/AllRecruitList'; +import { AsyncBoundaryWithQuery } from '@/lib'; +import { SeasonDropdownContent } from '../components/SeasonDropdownContent'; +import { useState } from 'react'; +import { ALL_RECRUITMENT } from '../components/SeasonDropdownContent'; + +export function AllRecruitment() { + const [selectedSeason, setSelectedSeason] = useState(ALL_RECRUITMENT); + + return ( + <> + + + + + + + + + <>} pendingFallback={<>}> + + + + + + ); +} diff --git a/src/app/(sidebar)/my-recruit/containers/ProgressingRecruitment.tsx b/src/app/(sidebar)/my-recruit/containers/ProgressingRecruitment.tsx deleted file mode 100644 index 2c4f05ba..00000000 --- a/src/app/(sidebar)/my-recruit/containers/ProgressingRecruitment.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Spacing } from '@/system/utils/Spacing'; -import { ShoeIcon } from './components/ShoeIcon'; -import { Dialog } from '@/system/components/Dialog/ShadcnDialog'; -import { motion } from 'framer-motion'; -import { color } from '@/system/token/color'; -import { cardList } from '../mock'; -import { BoxCard } from './components/Card/BoxCard'; -import { TouchButton } from '@/components/TouchButton'; - -export function ProgressingRecruitment() { - return ( - <> -
- - 현재 진행중인 공고 모아보기 -
- - - - 진행중인 공고가 없어요 - - - -
- {cardList.map((cardInfo) => ( - - ))} -
- - -
- - 더보기 - - - 간략히 보기 - -
- - ); -} diff --git a/src/app/(sidebar)/my-recruit/containers/ProgressingRecruitment/ProgressingRecruitList.tsx b/src/app/(sidebar)/my-recruit/containers/ProgressingRecruitment/ProgressingRecruitList.tsx new file mode 100644 index 00000000..82c7d27b --- /dev/null +++ b/src/app/(sidebar)/my-recruit/containers/ProgressingRecruitment/ProgressingRecruitList.tsx @@ -0,0 +1,99 @@ +import { BoxCard, MIN_CARD_WIDTH } from '@/app/(sidebar)/my-recruit/containers/components/Card/BoxCard'; +import { useGetProgressingRecruits } from '@/app/(sidebar)/my-recruit/api/useGetProgressingRecruits'; +import { Dialog } from '@/system/components/Dialog/ShadcnDialog'; +import { motion } from 'framer-motion'; +import { color } from '@/system/token/color'; +import { Spacing } from '@/system/utils/Spacing'; +import { TouchButton } from '@/components/TouchButton'; +import { useState } from 'react'; +import { If } from '@/system/utils/If'; +import { SwitchCase } from '@/system/utils/SwitchCase'; +import { usePatchRecruitStatus } from '../../api/usePatchRecruitStatus'; +import { useDeleteRecruit } from '../../api/useDeleteRecruit'; +import { useResizeObserver } from '@/hooks/useResizeObserver'; +import { AnimateHeight } from '@/system/utils/AnimateHeight'; + +const 최초_노출_카드_갯수 = 0; +const CARD_GAP = 16; + +export function ProgressingRecruitList() { + const recruitCards = useGetProgressingRecruits().data; + const [shouldShowMore, setShouldShowMore] = useState(false); + + const { mutate: patchRecruitStatus } = usePatchRecruitStatus(); + const { mutate: deleteRecruit } = useDeleteRecruit(); + + const [cardsPerRow, setCardsPerRow] = useState(최초_노출_카드_갯수); + const resizeRef = useResizeObserver(({ contentRect }) => { + setCardsPerRow(Math.floor(contentRect.width / (MIN_CARD_WIDTH + CARD_GAP))); + }); + const gridTemplateColumns = new Array(cardsPerRow).fill('1fr').join(' '); + + const recruitCardForShow = shouldShowMore ? recruitCards : recruitCards.slice(0, cardsPerRow); + const hasButton = recruitCards.length > cardsPerRow; + + return ( + <> + + + 진행중인 공고가 없어요 + + + ), + present: ( + <> + +
+ {recruitCardForShow.map((cardInfo) => ( + {}} + onRecruitDelete={deleteRecruit} + onRecruitStatusChange={(id, status) => { + patchRecruitStatus({ id, recruitStatus: status }); + }} + /> + ))} +
+
+ + +
+ setShouldShowMore(true)} + className="px-12 py-8 bg-neutral-5 rounded-[6px] text-caption1 text-neutral-50"> + 더보기 + + ), + collapse: ( + setShouldShowMore(false)} + className="px-12 py-8 bg-neutral-5 rounded-[6px] text-caption1 text-neutral-50"> + 간략히 보기 + + ), + }} + /> +
+
+ + ), + }} + /> + + + ); +} diff --git a/src/app/(sidebar)/my-recruit/containers/ProgressingRecruitment/ProgressingRecruitment.tsx b/src/app/(sidebar)/my-recruit/containers/ProgressingRecruitment/ProgressingRecruitment.tsx new file mode 100644 index 00000000..66cb2f6f --- /dev/null +++ b/src/app/(sidebar)/my-recruit/containers/ProgressingRecruitment/ProgressingRecruitment.tsx @@ -0,0 +1,19 @@ +import { Spacing } from '@/system/utils/Spacing'; +import { ShoeIcon } from '../components/ShoeIcon'; +import { AsyncBoundaryWithQuery } from '@/lib'; +import { ProgressingRecruitList } from './ProgressingRecruitList'; + +export function ProgressingRecruitment() { + return ( + <> +
+ + 현재 진행중인 공고 모아보기 +
+ + + + + + ); +} diff --git a/src/app/(sidebar)/my-recruit/containers/RightSidebar/CardList.tsx b/src/app/(sidebar)/my-recruit/containers/RightSidebar/CardList.tsx new file mode 100644 index 00000000..ee41624c --- /dev/null +++ b/src/app/(sidebar)/my-recruit/containers/RightSidebar/CardList.tsx @@ -0,0 +1,23 @@ +import { InfoCard } from '@/components/InfoCard'; +import { Draggable } from '@/lib/dnd-kit/dnd-kit'; +import { useGetCards } from '../../api/useGetCards'; + +interface CardListProps { + type: string; +} + +export function CardList({ type }: CardListProps) { + const cardList = useGetCards({ type }).data; + + return ( +
    + {cardList.map(({ tagList, ...info }) => ( +
  • + + + +
  • + ))} +
+ ); +} diff --git a/src/app/(sidebar)/my-recruit/containers/RightSidebar.tsx b/src/app/(sidebar)/my-recruit/containers/RightSidebar/RightSidebar.tsx similarity index 56% rename from src/app/(sidebar)/my-recruit/containers/RightSidebar.tsx rename to src/app/(sidebar)/my-recruit/containers/RightSidebar/RightSidebar.tsx index 40c962d5..ac688ed5 100644 --- a/src/app/(sidebar)/my-recruit/containers/RightSidebar.tsx +++ b/src/app/(sidebar)/my-recruit/containers/RightSidebar/RightSidebar.tsx @@ -2,24 +2,21 @@ import { Spacing } from '@/system/utils/Spacing'; import { TouchButton } from '@/components/TouchButton'; import { Icon } from '@/system/components'; import { color } from '@/system/token/color'; -import { mockInfoList } from '../mock'; -import { InfoCard } from '@/components/InfoCard'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/system/components/DropdownMenu/DropdownMenu'; -import { Draggable } from '@/lib/dnd-kit/dnd-kit'; import { motion } from 'framer-motion'; +import { CardList } from './CardList'; +import { useState } from 'react'; +import { INFO_CATEGORIES } from '@/app/(sidebar)/my-recruit/constant'; +import { AsyncBoundaryWithQuery } from '@/lib'; +import { cn } from '@/utils'; +import { Dropdown } from '@/system/components/index'; interface Props { onCloseButtonClick: () => void; } -const infoCategoryList = ['경험 정리', '자기소개서', '면접 질문']; - export function RightSidebar({ onCloseButtonClick }: Props) { + const [type, setType] = useState<(typeof INFO_CATEGORIES)[number]>(INFO_CATEGORIES[0]); + return (
내 정보 가져오기 - - + +
- 경험 정리 - + {type.replace('_', ' ')} +
-
- - {infoCategoryList.map((item) => ( - {item} + + + {INFO_CATEGORIES.map((category) => ( + setType(category)}> + {category.replace('_', ' ')} + ))} - -
+ +
카드를 공고 폴더로 드래그해보세요 -
    - {mockInfoList.map((info) => ( -
  • - - - -
  • - ))} -
+ + +
); diff --git a/src/app/(sidebar)/my-recruit/containers/components/Card/BoxCard.tsx b/src/app/(sidebar)/my-recruit/containers/components/Card/BoxCard.tsx index 6c69fd76..433c46fe 100644 --- a/src/app/(sidebar)/my-recruit/containers/components/Card/BoxCard.tsx +++ b/src/app/(sidebar)/my-recruit/containers/components/Card/BoxCard.tsx @@ -6,28 +6,39 @@ import { MoreButton } from '@/app/(sidebar)/my-recruit/containers/components/Car import { StatusButton } from '@/app/(sidebar)/my-recruit/containers/components/Card/common/StatusButton'; import { Dialog } from '@/system/components/Dialog/ShadcnDialog'; import { DueDateDialog } from '../DueDateDialog'; +import { RecruitCard } from '@/app/(sidebar)/my-recruit/type'; -export type ProgressingCardType = { - id: number; - type: '서류 마감' | '1차 면접' | '2차 면접'; - status: '지원 완료' | '서류 통과' | '서류 탈락'; - dueDate: Date | null; - period: string; - title: string; -}; +interface BoxCardProps extends RecruitCard { + onRecruitDelete: (id: number) => void; + onRecruitStatusChange: (id: number, status: string) => void; + onDuedateAppend: () => void; +} + +export const MIN_CARD_WIDTH = 250; + +export function BoxCard({ + id, + title, + recruitStatus, + season, + nearestSchedule, + onDuedateAppend, + onRecruitStatusChange, + onRecruitDelete, +}: BoxCardProps) { + const minWidth = MIN_CARD_WIDTH; -export function BoxCard({ type, title, status, dueDate, period }: ProgressingCardType) { return ( -
+
- {dueDate == null ? ( + {nearestSchedule == null ? ( 공고 일정을 등록해주세요 - + onDuedateAppend()} /> ) : ( @@ -35,17 +46,20 @@ export function BoxCard({ type, title, status, dueDate, period }: ProgressingCar
- {type} D-{dday(dueDate)} + {nearestSchedule.recruitScheduleStage} D-{dday(nearestSchedule.deadLine)}
- + onRecruitDelete(id)} /> )}
-
{period}
- +
{season}
+ onRecruitStatusChange(id, status)} + />
{title} diff --git a/src/app/(sidebar)/my-recruit/containers/components/Card/RowCard.tsx b/src/app/(sidebar)/my-recruit/containers/components/Card/RowCard.tsx index 28742e06..398dbc7b 100644 --- a/src/app/(sidebar)/my-recruit/containers/components/Card/RowCard.tsx +++ b/src/app/(sidebar)/my-recruit/containers/components/Card/RowCard.tsx @@ -1,3 +1,4 @@ +import { match } from 'ts-pattern'; import { If } from '@/system/utils/If'; import { Spacing } from '@/system/utils/Spacing'; import { Icon } from '@/system/components'; @@ -6,34 +7,48 @@ import { dday } from '@/utils/date'; import { MoreButton } from '@/app/(sidebar)/my-recruit/containers/components/Card/common/MoreButton'; import { StatusButton } from './common/StatusButton'; import { cn } from '@/utils'; +import { RecruitCard } from '@/app/(sidebar)/my-recruit/type'; -interface RowCardProps { - id: number; - type: '서류 마감' | '1차 면접' | '2차 면접'; - status: '지원 완료' | '서류 통과' | '서류 탈락'; - dueDate: Date | null; - period: string; - title: string; +type RowCardProps = RecruitCard & { highlighted?: boolean; -} + onRecruitDelete: (id: number) => void; + onRecruitStatusChange: (id: number, status: string) => void; +}; + +export function RowCard({ + id, + title, + recruitStatus, + season, + nearestSchedule, + highlighted = false, + onRecruitDelete, + onRecruitStatusChange, +}: RowCardProps) { + const isOutOfDate = nearestSchedule != null && dday(nearestSchedule.deadLine) < 0; + + const pointerEventsClassName = isOutOfDate ? 'pointer-events-none' : 'pointer-events-auto'; + const rightMarkBackgroundColorClassName = match({ isOutOfDate, highlighted }) + .with({ isOutOfDate: true }, () => 'bg-neutral-10') + .with({ highlighted: true }, () => 'bg-mint-40') + .otherwise(() => 'bg-neutral-95'); -export function RowCard({ type, title, status, dueDate, period, highlighted = false }: RowCardProps) { return ( -
-
+
+
- {period} + {season} - +
- {type} D-{dday(dueDate!)} + {recruitStatus} D-{dday(nearestSchedule?.deadLine!)}
@@ -41,9 +56,12 @@ export function RowCard({ type, title, status, dueDate, period, highlighted = fa {title}
- + onRecruitStatusChange(id, status)} + /> - + onRecruitDelete(id)} />
diff --git a/src/app/(sidebar)/my-recruit/containers/components/Card/common/MoreButton.tsx b/src/app/(sidebar)/my-recruit/containers/components/Card/common/MoreButton.tsx index c81693de..c604a2b8 100644 --- a/src/app/(sidebar)/my-recruit/containers/components/Card/common/MoreButton.tsx +++ b/src/app/(sidebar)/my-recruit/containers/components/Card/common/MoreButton.tsx @@ -1,24 +1,25 @@ import { Icon } from '@/system/components'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/system/components/DropdownMenu/DropdownMenu'; +import { Dropdown } from '@/system/components'; import { color } from '@/system/token/color'; -export function MoreButton() { +interface MoreButtonProps { + onDeleteClick: () => void; +} + +export function MoreButton({ onDeleteClick }: MoreButtonProps) { return ( - - + + - - - - -
삭제하기
-
-
-
+ + + +
+ +
삭제하기
+
+
+
+ ); } diff --git a/src/app/(sidebar)/my-recruit/containers/components/Card/common/StatusButton.tsx b/src/app/(sidebar)/my-recruit/containers/components/Card/common/StatusButton.tsx index 45e2f89d..bdada9cd 100644 --- a/src/app/(sidebar)/my-recruit/containers/components/Card/common/StatusButton.tsx +++ b/src/app/(sidebar)/my-recruit/containers/components/Card/common/StatusButton.tsx @@ -1,59 +1,44 @@ import { SwitchCase } from '@/system/utils/SwitchCase'; import { Icon } from '@/system/components'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuSeparator, -} from '@/system/components/DropdownMenu/DropdownMenu'; +import { Dropdown } from '@/system/components'; import { color } from '@/system/token/color'; import { cn } from '@/utils'; +import { recruitStatusList } from '@/app/(sidebar)/my-recruit/constant'; interface Props { currentStatus: string; + onRecruitStatusChange: (status: string) => void; } -export function StatusButton({ currentStatus }: Props) { +export function StatusButton({ currentStatus, onRecruitStatusChange }: Props) { return ( - - + +
{currentStatus} - +
-
- - {statusList.map((item, index) => ( + + + {recruitStatusList.map((item, index) => ( + onRecruitStatusChange(item.text)}> {item.text} - + ) : null, - border: , + border: , }} /> ))} - -
+ + ); } - -const statusList = [ - { variant: 'text', text: '지원 준비' }, - { variant: 'text', text: '지원 완료' }, - { variant: 'border' }, - { variant: 'text', text: '서류 통과' }, - { variant: 'text', text: '서류 탈락' }, - { variant: 'border' }, - { variant: 'text', text: '면접 통과' }, - { variant: 'text', text: '면접 탈락' }, - { variant: 'border' }, - { variant: 'text', text: '최종 합격' }, - { variant: 'text', text: '최종 탈락' }, -] as const; diff --git a/src/app/(sidebar)/my-recruit/containers/components/DueDateDialog.tsx b/src/app/(sidebar)/my-recruit/containers/components/DueDateDialog.tsx index 6866cfb6..d0f2c5fd 100644 --- a/src/app/(sidebar)/my-recruit/containers/components/DueDateDialog.tsx +++ b/src/app/(sidebar)/my-recruit/containers/components/DueDateDialog.tsx @@ -1,28 +1,42 @@ -import { Button, Icon } from '@/system/components'; +import { recruitStatusList } from '@/app/(sidebar)/my-recruit/constant'; +import { Button, Dropdown, Icon } from '@/system/components'; import { Calendar } from '@/system/components/Calendar/Calendar'; import { Popover, PopoverContent, PopoverTrigger } from '@/system/components/Popover/Popover'; import { color } from '@/system/token/color'; import { Spacing } from '@/system/utils/Spacing'; -import { cn } from '@/utils'; import clsx from 'clsx'; import { format } from 'date-fns/format'; import { motion } from 'framer-motion'; import { useState } from 'react'; +import { recruitScheduleStageList } from '../../constant'; interface DueDateDialogProps { + title: string; + onDuedateAppend: () => void; title?: string; } export function DueDateDialog({ title }: DueDateDialogProps) { const [selectedDate, setSelectedDate] = useState(); + const [currentRecruitStage, setCurrentRecruitStage] = useState(recruitStatusList[3].text); + const [dueDateList, setDueDateList] = useState< + Array<{ + recruitScheduleStage: string | null; + deadLine: `${number}-${number}-${number}` | null; + }> + >([]); const isDateSelected = selectedDate != null; + const activatedAddButton = + dueDateList.length !== 0 && dueDateList[0].deadLine != null && dueDateList[0].recruitScheduleStage != null; return ( -
+
- {title && } + + {title} + 의 공고 일정 등록하기 {title ? `${title}의 공고 일정 등록하기` : '공고 일정 등록하기'} @@ -33,7 +47,25 @@ export function DueDateDialog({ title }: DueDateDialogProps) { {/* 마감일 입력 */}
- 서류마감 + + +
+ {currentRecruitStage} + +
+
+ + {recruitScheduleStageList.map((item, index) => ( + setCurrentRecruitStage(item)}> + {item} + + ))} + +
- diff --git a/src/app/(sidebar)/my-recruit/containers/components/SeasonDropdownContent.tsx b/src/app/(sidebar)/my-recruit/containers/components/SeasonDropdownContent.tsx new file mode 100644 index 00000000..fef10790 --- /dev/null +++ b/src/app/(sidebar)/my-recruit/containers/components/SeasonDropdownContent.tsx @@ -0,0 +1,28 @@ +import { Dropdown } from '@/system/components'; +import { useGetSeasons } from '../../api/useGetSeasons'; + +interface SeasonDropdownContentProps { + selectedSeason: string; + onItemClick: (item: string) => void; +} + +export const ALL_RECRUITMENT = '모든 공고'; + +export function SeasonDropdownContent({ selectedSeason, onItemClick }: SeasonDropdownContentProps) { + const seasons = useGetSeasons().data; + const seasonsIncludeAll = [{ name: ALL_RECRUITMENT }, ...seasons]; + + return ( + <> + {seasonsIncludeAll.map((season) => ( + onItemClick(season.name)}> + {season.name} + + ))} + + ); +} diff --git a/src/app/(sidebar)/my-recruit/mock.ts b/src/app/(sidebar)/my-recruit/mock.ts deleted file mode 100644 index 8773bb29..00000000 --- a/src/app/(sidebar)/my-recruit/mock.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { ProgressingCardType } from './containers/components/Card/BoxCard'; -import { InfoCardType } from '@/types/info'; - -export const cardList: ProgressingCardType[] = [ - { - id: 1, - type: '1차 면접', - status: '서류 통과', - dueDate: null, - period: '2024 상반기', - title: '디프만 15기 디자이너 직군', - }, - { - id: 2, - type: '2차 면접', - status: '지원 완료', - dueDate: new Date('2024-08-20'), - period: '2024 하반기', - title: '2024 네이버 프로덕트 디자이너 신입공채 지원서 제출', - }, -]; - -export const mockInfoList: InfoCardType[] = [ - { - id: 1, - title: '제목', - updatedDate: '2024-07-20T20:00:00', - cardTagList: [ - { - id: 1, - name: '인성태그1', - type: '인성', - }, - { - id: 2, - name: '역량태그1', - type: '역량', - }, - ], - }, - { - id: 2, - title: 'test title2', - updatedDate: '2024-07-10T20:00:00', - cardTagList: [ - { - id: 1, - name: '인성태그1', - type: '인성', - }, - { - id: 2, - name: '역량태그1', - type: '역량', - }, - ], - }, - { - id: 3, - title: '제목제목제목제목제목제목제목제목제목제목제목제목제목제목제목제목제목', - updatedDate: '2024-07-15T20:00:00', - cardTagList: [ - { - id: 1, - name: '인성태그1', - type: '인성', - }, - { - id: 2, - name: '역량태그1', - type: '역량', - }, - ], - }, -]; diff --git a/src/app/(sidebar)/my-recruit/page.tsx b/src/app/(sidebar)/my-recruit/page.tsx index 6d31fa62..1a1a55fb 100644 --- a/src/app/(sidebar)/my-recruit/page.tsx +++ b/src/app/(sidebar)/my-recruit/page.tsx @@ -3,54 +3,88 @@ import { Icon } from '@/system/components'; import { TouchButton } from '@/components/TouchButton'; import { Spacing } from '@/system/utils/Spacing'; -import { ProgressingRecruitment } from './containers/ProgressingRecruitment'; -import { AllRecruitment } from './containers/AllRecruitment'; -import { useState } from 'react'; +import { ProgressingRecruitment } from './containers/ProgressingRecruitment/ProgressingRecruitment'; +import { AllRecruitment } from './containers/AllRecruitment/AllRecruitment'; +import { useRef, useState } from 'react'; import { Dialog } from '@/system/components/Dialog/ShadcnDialog'; import { NewRecruitDialogContent } from './components/NewRecruitDialogContent/NewRecruitDialogContent'; -import { RightSidebar } from './containers/RightSidebar'; -import { DndContextWithOverlay } from '@/lib/dnd-kit/dnd-kit'; +import { RightSidebar } from './containers/RightSidebar/RightSidebar'; +import { DndContextWithOverlay, DragEndEvent } from '@/lib/dnd-kit/dnd-kit'; import { InfoCard } from '@/components/InfoCard'; import { AnimatePresence } from 'framer-motion'; +import { usePostRecruit } from './api/usePostRecruit'; +import { CardData } from './components/NewRecruitDialogContent/NewRecruitDialogContent'; +import { cn } from '@/utils'; +import { color } from '@/system/token/color'; +import { usePostCardToRecruit } from './api/usePostCardToRecruit'; +import { useScroll } from '@/hooks/useScroll'; export default function MyRecruit() { const [sidebarOpened, setSidebarOpened] = useState(false); + const headerRef = useRef(null); + const [isSticky, setIsSticky] = useState(false); + useScroll(headerRef.current, (y) => setIsSticky(y > 100)); + + const { mutate: mutatePostCard } = usePostRecruit(); + const { mutate: mutatePostCardToRecruit } = usePostCardToRecruit(); + const onCreateNewRecruitCard = (data: CardData) => mutatePostCard(data); + + const onDragEnd = ({ over, active }: DragEndEvent) => { + if (over == null || active == null || typeof over.id !== 'number' || typeof active.id !== 'number') { + return; + } + mutatePostCardToRecruit({ recruitId: over.id, cardId: active.id }); + }; + return ( - + -
-
-
-

내 공고 뽀각

-
- setSidebarOpened(!sidebarOpened)}> - - 내 정보 가져오기 - - -
- - - 새 공고 - -
-
+
+
+
+ +
+

내 공고

+
+ setSidebarOpened(!sidebarOpened)}> + + + 내 정보 가져오기 + + + +
+ + + 새 공고 + +
+
+
+ +
+
+ + +
- - - -
+ {sidebarOpened ? setSidebarOpened(false)} /> : null}
+ - +
diff --git a/src/app/(sidebar)/my-recruit/type.ts b/src/app/(sidebar)/my-recruit/type.ts new file mode 100644 index 00000000..2938bd7b --- /dev/null +++ b/src/app/(sidebar)/my-recruit/type.ts @@ -0,0 +1,13 @@ +export interface RecruitCard { + id: number; + title: string; + season: string; + siteUrl: string; + recruitStatus: string; + createdDate: string; + nearestSchedule: { + id: number; + recruitScheduleStage: string; + deadLine: `${number}-${number}-${number}`; + } | null; +} diff --git a/src/app/(sidebar)/write/[id]/api/useGetCardDetail/useGetCardDetail.ts b/src/app/(sidebar)/write/[id]/api/useGetCardDetail/useGetCardDetail.ts index 77feb0b4..006e9545 100644 --- a/src/app/(sidebar)/write/[id]/api/useGetCardDetail/useGetCardDetail.ts +++ b/src/app/(sidebar)/write/[id]/api/useGetCardDetail/useGetCardDetail.ts @@ -12,7 +12,7 @@ export interface GetCardDetailResponse { } const getCardDetail = (cardId: number) => - http.get({ + http.get({ url: `/cards/${cardId}`, }); diff --git a/src/components/InfoCard.tsx b/src/components/InfoCard.tsx index 314ea28c..0cf07ac5 100644 --- a/src/components/InfoCard.tsx +++ b/src/components/InfoCard.tsx @@ -8,6 +8,7 @@ import { } from '@/system/components/DropdownMenu/DropdownMenu'; import { Tag } from '@/system/components/index'; import { color } from '@/system/token/color'; +import { If } from '@/system/utils/If'; import { InfoCardType, TAG_TYPE_COLOR } from '@/types/info'; import { formatToYYMMDD } from '@/utils/date'; import Link from 'next/link'; @@ -55,14 +56,22 @@ export function InfoCard({ id, title, updatedDate, tagList }: InfoCardProps) {
- {tagList && - tagList.map(({ id, type, name }) => ( - - {name} - - ))} + {tagList.map(({ id, type, name }) => ( + + {name} + + ))}
+ +
+ {tagList?.map(({ id, type, name }) => ( + + {name} + + ))} +
+
); } diff --git a/src/container/Sidebar/Sidebar.tsx b/src/container/Sidebar/Sidebar.tsx index e77c4aca..2d061f9d 100644 --- a/src/container/Sidebar/Sidebar.tsx +++ b/src/container/Sidebar/Sidebar.tsx @@ -33,7 +33,7 @@ export function Sidebar() {
- + {/* */}
@@ -76,7 +76,7 @@ export function Sidebar() {
- + {/* */}
diff --git a/src/hooks/useResizeObserver.ts b/src/hooks/useResizeObserver.ts new file mode 100644 index 00000000..8cf834cb --- /dev/null +++ b/src/hooks/useResizeObserver.ts @@ -0,0 +1,35 @@ +import { useRef } from 'react'; +import { usePreservedCallback } from './usePreservedCallback'; +import { useIsomorphicLayoutEffect } from 'framer-motion'; + +export type OnResize = (entry: ResizeObserverEntry) => void; + +export function useResizeObserver(onResize: OnResize) { + const ref = useRef(null); + const resizeCallback = usePreservedCallback(onResize); + + useIsomorphicLayoutEffect(() => { + let rAF = 0; + + if (ref.current) { + const resizeObserver = new ResizeObserver((entries) => { + if (entries[0] != null) { + cancelAnimationFrame(rAF); + + rAF = requestAnimationFrame(() => resizeCallback(entries[0])); + } + }); + + resizeObserver.observe(ref.current); + + return () => { + cancelAnimationFrame(rAF); + if (ref.current != null) { + resizeObserver.unobserve(ref.current); + } + }; + } + }, [resizeCallback]); + + return ref; +} diff --git a/src/hooks/useScroll.ts b/src/hooks/useScroll.ts new file mode 100644 index 00000000..8eeea843 --- /dev/null +++ b/src/hooks/useScroll.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; + +export function useScroll(target: HTMLDivElement | null, callback: (topOffsetY: number) => void) { + useEffect(() => { + const element = target ?? window; + + const callback2 = () => { + const topOffsetY = target?.offsetTop ?? window.scrollY; + callback(topOffsetY); + }; + + element.addEventListener('scroll', callback2); + + return () => { + element.removeEventListener('scroll', callback2); + }; + }, [target, callback]); +} diff --git a/src/lib/dnd-kit/Draggable.tsx b/src/lib/dnd-kit/Draggable.tsx index c4e837b3..c1a81261 100644 --- a/src/lib/dnd-kit/Draggable.tsx +++ b/src/lib/dnd-kit/Draggable.tsx @@ -1,5 +1,6 @@ import React, { PropsWithChildren } from 'react'; import { useDraggable } from '@dnd-kit/core'; +import { motion } from 'framer-motion'; interface DraggableProps { id: string | number; @@ -13,8 +14,8 @@ export function Draggable({ id, children, dataForOverlay }: PropsWithChildren + {children} -
+ ); } diff --git a/src/system/components/Dropdown/anatomy.ts b/src/system/components/Dropdown/anatomy.ts index 81416eae..d08c88fd 100644 --- a/src/system/components/Dropdown/anatomy.ts +++ b/src/system/components/Dropdown/anatomy.ts @@ -1 +1,7 @@ -export type DropdownAnatomy = 'trigger-arrow' | 'content' | 'separator' | 'checkbox-item'; +export type DropdownAnatomy = + | 'trigger' + | 'trigger-background' + | 'trigger-arrow' + | 'content' + | 'separator' + | 'checkbox-item'; diff --git a/src/system/components/Dropdown/compounds/Root.tsx b/src/system/components/Dropdown/compounds/Root.tsx index f7254d78..d0b2091a 100644 --- a/src/system/components/Dropdown/compounds/Root.tsx +++ b/src/system/components/Dropdown/compounds/Root.tsx @@ -5,15 +5,16 @@ import { PropsWithChildren, useState } from 'react'; import { DropdownProvider } from '../context'; import { useDropdownStyles } from '../styles/useDropdownStyles'; import { useDropdownLogics } from '../logics/useDropdownLogics'; +import { DropdownStyleProps } from '@/system/components/Dropdown/styles/useDropdownStyles'; -interface RootProps { +interface RootProps extends Partial { defaultOpen?: boolean; } // 추후 Controlled방식도 지원 -export function Root({ defaultOpen = false, children }: PropsWithChildren) { +export function Root({ defaultOpen = false, colorVariant = 'grey', children }: PropsWithChildren) { const [open, setOpen] = useState(defaultOpen); - const dropdownStyles = useDropdownStyles(); + const dropdownStyles = useDropdownStyles({ colorVariant }); const dropdownLogics = useDropdownLogics({ onOpenChange: setOpen }); return ( diff --git a/src/system/components/Dropdown/compounds/Trigger.tsx b/src/system/components/Dropdown/compounds/Trigger.tsx index d0e8b521..c73e77ad 100644 --- a/src/system/components/Dropdown/compounds/Trigger.tsx +++ b/src/system/components/Dropdown/compounds/Trigger.tsx @@ -2,9 +2,20 @@ import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu'; import { ComponentProps } from 'react'; +import { useDropdownContext } from '../context'; +import { mergeProps } from '@/utils/mergeProps'; export type TriggerProps = ComponentProps; -export function Trigger({ children }: TriggerProps) { - return {children}; +export function Trigger({ children, ...restProps }: TriggerProps) { + const { styles } = useDropdownContext(); + + return ( + +
+
+ {children} +
+ + ); } diff --git a/src/system/components/Dropdown/logics/useDropdownLogics.ts b/src/system/components/Dropdown/logics/useDropdownLogics.ts index fbdee987..42dd896c 100644 --- a/src/system/components/Dropdown/logics/useDropdownLogics.ts +++ b/src/system/components/Dropdown/logics/useDropdownLogics.ts @@ -10,6 +10,8 @@ export function useDropdownLogics({ onOpenChange }: Props): DropdownLogics { return { content: {}, separator: {}, + trigger: {}, + 'trigger-background': {}, 'trigger-arrow': {}, 'checkbox-item': { onClick: () => onOpenChange(false), diff --git a/src/system/components/Dropdown/styles/useDropdownStyles.ts b/src/system/components/Dropdown/styles/useDropdownStyles.ts index b687e82c..19720f29 100644 --- a/src/system/components/Dropdown/styles/useDropdownStyles.ts +++ b/src/system/components/Dropdown/styles/useDropdownStyles.ts @@ -1,24 +1,49 @@ +import { cn } from '@/utils'; import { DropdownAnatomy } from '../anatomy'; +import { match } from 'ts-pattern'; interface DropdownStyle { className?: string; } +export interface DropdownStyleProps { + colorVariant: 'black' | 'grey'; +} + export type DropdownStyles = Record; -export function useDropdownStyles(): Record { +export function useDropdownStyles({ colorVariant }: DropdownStyleProps): Record { + const backgroundClassAsColorVariant = match(colorVariant) + .with('black', () => 'hover:bg-neutral-3 active:bg-neutral-5') + .with('grey', () => 'hover:bg-neutral-5 active:bg-neutral-10') + .exhaustive(); + + const triggerClassAsColorVariant = match(colorVariant) + .with('black', () => 'text-neutral-80') + .with('grey', () => 'text-neutral-40') + .exhaustive(); + return { content: { className: - 'flex flex-col py-[8px] shadow-[0px_2px_8px_0px_rgba(0,0,0,0.12),0px_0px_1px_0px_rgba(0,0,0,0.08)] min-w-[170px] bg-white rounded-[12px] border-[1px] hover:border-neutral-20', + 'flex flex-col py-[8px] shadow-[0px_2px_8px_0px_rgba(0,0,0,0.12),0px_0px_1px_0px_rgba(0,0,0,0.08)] min-w-[170px] bg-white rounded-[12px] border-[1px] hover:border-neutral-20 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 mt-[8px]', }, separator: { - className: 'h-[8px] bg-nuetral-3 w-full', + className: 'h-[1px] bg-neutral-3 w-full', + }, + trigger: { + className: cn(triggerClassAsColorVariant, 'relative'), + }, + 'trigger-background': { + className: cn( + backgroundClassAsColorVariant, + 'z-[-1] w-[calc(100%+6px)] h-[calc(100%+8px)] absolute right-0 top-[50%] translate-y-[-50%] rounded-[6px]', + ), }, 'trigger-arrow': {}, 'checkbox-item': { className: - 'flex justify-between items-center mx-[8px] my-[4px] px-[8px] py-[4px] text-label1 font-medium text-neutral-80 rounded-[6px] hover:bg-neutral-3 disabled:text-neutral-30 disabled:bg-white', + 'flex justify-between items-center mx-[8px] my-[4px] px-[8px] py-[4px] text-label1 font-medium rounded-[6px] hover:bg-neutral-3 disabled:text-neutral-30 disabled:bg-white', }, }; } diff --git a/src/system/components/Icon/SVG/FolderFill.tsx b/src/system/components/Icon/SVG/FolderFill.tsx index 3cb4d33a..e9ca103b 100644 --- a/src/system/components/Icon/SVG/FolderFill.tsx +++ b/src/system/components/Icon/SVG/FolderFill.tsx @@ -4,8 +4,8 @@ export function FolderFill({ size, color }: IconBaseType) { return ( diff --git a/src/system/utils/AnimateHeight.tsx b/src/system/utils/AnimateHeight.tsx new file mode 100644 index 00000000..2187b289 --- /dev/null +++ b/src/system/utils/AnimateHeight.tsx @@ -0,0 +1,34 @@ +import { useResizeObserver } from '@/hooks/useResizeObserver'; +import { animate, motion, useMotionValue } from 'framer-motion'; +import { ReactNode, forwardRef, useState } from 'react'; + +interface AnimateHeightProps { + children?: ReactNode; +} + +/** + * @description 동적으로 변하는 높이에 대응합니다. + */ +export const AnimateHeight = forwardRef(function AnimateHeight({ children }, ref) { + const [updated, setUpdated] = useState(false); + const heightMotionValue = useMotionValue(-1); + + const resizeRef = useResizeObserver(({ contentRect }) => { + if (updated === false) { + setUpdated(true); + // heightMotionValue.set(contentRect.height); + return; + } + heightMotionValue.set(contentRect.height); + // animate(heightMotionValue, contentRect.height, { duration: 0.7 }); + }); + + return ( + +
{children}
+ + ); +}); diff --git a/src/system/utils/Spacing.tsx b/src/system/utils/Spacing.tsx index 1dc4453c..c4b0fb4d 100644 --- a/src/system/utils/Spacing.tsx +++ b/src/system/utils/Spacing.tsx @@ -1,8 +1,10 @@ -interface Props { +import { ComponentProps } from 'react'; + +interface Props extends ComponentProps<'div'> { size: number; direction?: 'column' | 'row'; } -export function Spacing({ size, direction = 'column' }: Props) { - return
; +export function Spacing({ size, direction = 'column', ...restProps }: Props) { + return
; } diff --git a/src/utils/date.ts b/src/utils/date.ts index 130b1166..976c46b3 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -14,8 +14,9 @@ export const formatToYYMMDD = (dateString: string, { separator = '' }: Option = return [yy, mm, dd].join(separator); }; -export const dday = (target: Date) => { +export const dday = (target: Date | string) => { + const targetDate = typeof target === 'string' ? new Date(target) : target; const today = new Date(); - return differenceInDays(target, today); + return differenceInDays(targetDate, today); }; diff --git a/yarn.lock b/yarn.lock index dee32a39..f92b2ad4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5517,6 +5517,7 @@ __metadata: tailwindcss: ^3.4.1 tailwindcss-animate: ^1.0.7 tippy.js: ^6.3.7 + ts-pattern: ^5.3.1 typescript: 5.1 webpack: ^5.92.1 languageName: unknown @@ -13147,6 +13148,13 @@ __metadata: languageName: node linkType: hard +"ts-pattern@npm:^5.3.1": + version: 5.3.1 + resolution: "ts-pattern@npm:5.3.1" + checksum: e9d59c9139a7861ce1db25f25ebd899456a6fade41a209b8755e7c615564eb5dfd84e086500bb9b4fb57326eb7fef61c4befa920b3137eaf5f314192ddc88ee1 + languageName: node + linkType: hard + "ts-pnp@npm:^1.1.6": version: 1.2.0 resolution: "ts-pnp@npm:1.2.0"