diff --git a/frontend/src/features/dashboard/my-collections/components/QnaCardListSection.tsx b/frontend/src/features/dashboard/my-collections/components/QnaCardListSection.tsx index 61cd10407..b32209777 100644 --- a/frontend/src/features/dashboard/my-collections/components/QnaCardListSection.tsx +++ b/frontend/src/features/dashboard/my-collections/components/QnaCardListSection.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { CaretDownIcon } from '@/designs/assets' import { Button, PlainCombobox } from '@/designs/components' -import { QnaCard } from '@/features/dashboard/my-interviews/components/questions' +import QnaCard from '@/features/dashboard/my-interviews/components/questions/list/qna-card/QnaCard' import type { InterviewResultStatus } from '@/features/dashboard/my-interviews/constants/constants' import type { InterviewType } from '@/types/interview' diff --git a/frontend/src/features/dashboard/my-interviews/components/filter/FilterSortControls.tsx b/frontend/src/features/dashboard/my-interviews/components/filter/FilterSortControls.tsx index c63faa07a..114a1c19e 100644 --- a/frontend/src/features/dashboard/my-interviews/components/filter/FilterSortControls.tsx +++ b/frontend/src/features/dashboard/my-interviews/components/filter/FilterSortControls.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { CaretDownIcon, FilterIcon } from '@/designs/assets' import { Button, PlainCombobox } from '@/designs/components' -import type { LabelValueType } from '@/types/global' +import { INTERVIEW_SORT_OPTIONS } from '@/features/dashboard/my-interviews/constants/constants' import type { InterviewFilter } from '@/types/interview' import InterviewFilterModal from './InterviewFilterModal' @@ -24,12 +24,12 @@ export default function FilterSortControls({ filter, onFilterChange }: FilterSor onFilterChange({ ...filter, sort })} trigger={ } @@ -44,10 +44,3 @@ export default function FilterSortControls({ filter, onFilterChange }: FilterSor ) } - -const SORT_OPTIONS: LabelValueType[] = [ - { label: '면접 일시 최신순', value: 'date-latest' }, - { label: '면접 일시 오래된순', value: 'date-oldest' }, - { label: '최신 업데이트순', value: 'updated' }, - { label: '가나다순', value: 'alphabetical' }, -] diff --git a/frontend/src/features/dashboard/my-interviews/components/filter/InterviewFilterModal.tsx b/frontend/src/features/dashboard/my-interviews/components/filter/InterviewFilterModal.tsx index 7b84d9db8..4c89efe69 100644 --- a/frontend/src/features/dashboard/my-interviews/components/filter/InterviewFilterModal.tsx +++ b/frontend/src/features/dashboard/my-interviews/components/filter/InterviewFilterModal.tsx @@ -14,15 +14,15 @@ type InterviewFilterModalProps = { export default function InterviewFilterModalContent({ open, filter, onApply, onClose }: InterviewFilterModalProps) { const [draft, setDraft] = useState(filter) - const toggleItem = (key: keyof Pick, value: string) => { + const toggleItem = (key: K, value: InterviewFilter[K][number]) => { setDraft((prev) => { - const list = prev[key] + const list = prev[key] as string[] const updated = list.includes(value) ? list.filter((v) => v !== value) : [...list, value] return { ...prev, [key]: updated } }) } - const handleReset = () => setDraft(EMPTY_FILTER) + const handleReset = () => setDraft((prev) => ({ ...EMPTY_FILTER, keyword: prev.keyword })) const handleApply = () => { onApply(draft) @@ -37,14 +37,14 @@ export default function InterviewFilterModalContent({ open, filter, onApply, onC items={INTERVIEW_TYPE_OPTIONS} columns={3} selected={draft.interviewType} - onToggle={(v) => toggleItem('interviewType', v)} + onToggle={(v) => toggleItem('interviewType', v as InterviewFilter['interviewType'][number])} /> toggleItem('resultStatus', v)} + onToggle={(v) => toggleItem('resultStatus', v as InterviewFilter['resultStatus'][number])} />
기간 diff --git a/frontend/src/features/dashboard/my-interviews/components/interviews/DraftSection.tsx b/frontend/src/features/dashboard/my-interviews/components/interviews/DraftSection.tsx deleted file mode 100644 index 158f8f0c6..000000000 --- a/frontend/src/features/dashboard/my-interviews/components/interviews/DraftSection.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { INTERVIEW_TYPE_LABEL } from '@/constants/interviews' -import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/designs/components' -import { MOCK_DRAFTS } from '../../example' - -type DraftSectionProps = { - interviewReviewStatus: 'not_logged' | 'log_draft' | 'self_review_draft' | 'debrief_completed' -} - -export default function DraftSection({ interviewReviewStatus }: DraftSectionProps) { - const items = MOCK_DRAFTS - const draftType = interviewReviewStatus === 'log_draft' ? '기록' : '회고' - - return ( -
-
- -
-

임시저장한 {draftType}

- - - - 응시일 - 회사 - 직무 - 면접 유형 - - - - {items.map(({ interviewId, interviewStartAt, company, jobCategoryName, interviewType }) => ( - - {interviewStartAt} - {company} - {jobCategoryName} - {INTERVIEW_TYPE_LABEL[interviewType]} - - ))} - -
-
- ) -} diff --git a/frontend/src/features/dashboard/my-interviews/components/interviews/InterviewListSection.tsx b/frontend/src/features/dashboard/my-interviews/components/interviews/InterviewListSection.tsx deleted file mode 100644 index 3c3fed91d..000000000 --- a/frontend/src/features/dashboard/my-interviews/components/interviews/InterviewListSection.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { InterviewFilter } from '@/types/interview' -import { MOCK_COMPLETED } from '../../example' -import InterviewCard from './InterviewCard' - -type InterviewListSectionProps = { - filter: InterviewFilter -} - -export default function InterviewListSection({ filter }: InterviewListSectionProps) { - const filteredInterviews = MOCK_COMPLETED.filter((item) => !filter.keyword || item.company.includes(filter.keyword)) - - return ( -
-
- {filteredInterviews.map((item, i) => ( - - ))} -
-
- ) -} diff --git a/frontend/src/features/dashboard/my-interviews/components/interviews/InterviewsTab.tsx b/frontend/src/features/dashboard/my-interviews/components/interviews/InterviewsTab.tsx new file mode 100644 index 000000000..6d89fbde5 --- /dev/null +++ b/frontend/src/features/dashboard/my-interviews/components/interviews/InterviewsTab.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react' +import { FilterSortControls, SearchBar, SearchResultBar } from '@/features/dashboard/my-interviews/components/filter' +import DraftSection from '@/features/dashboard/my-interviews/components/interviews/draft/DraftSection' +import InterviewListSection from '@/features/dashboard/my-interviews/components/interviews/list/InterviewListSection' +import { EMPTY_FILTER } from '@/features/dashboard/my-interviews/constants/constants' +import type { InterviewFilter } from '@/types/interview' + +export default function InterviewsTab() { + const [filter, setFilter] = useState(EMPTY_FILTER) + const isSearching = filter.keyword.length > 0 + + return ( + <> +
+ setFilter((prev) => ({ ...prev, keyword }))} /> +
+ {!isSearching && ( +
+

임시저장 항목

+
+ + +
+
+ )} +
+
+ {isSearching ? ( + setFilter((prev) => ({ ...prev, keyword: '' }))} /> + ) : ( +

내가 복기 완료한 면접

+ )} + +
+ +
+ + ) +} diff --git a/frontend/src/features/dashboard/my-interviews/components/interviews/draft/DraftSection.tsx b/frontend/src/features/dashboard/my-interviews/components/interviews/draft/DraftSection.tsx new file mode 100644 index 000000000..d6b2a3320 --- /dev/null +++ b/frontend/src/features/dashboard/my-interviews/components/interviews/draft/DraftSection.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router' +import { useGetMyInterviewDrafts } from '@/apis/generated/interview-my-api/interview-my-api' +import type { GetMyInterviewDraftsInterviewDraftType } from '@/apis/generated/refit-api.schemas' +import { getInterviewNavigationPath } from '@/constants/interviewReviewStatusRoutes' +import { INTERVIEW_TYPE_LABEL } from '@/constants/interviews' +import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/designs/components' +import { mapDraftInterviewRow } from '@/features/dashboard/my-interviews/components/interviews/mappers' +import DraftListModal from './draft-list-modal/DraftListModal' + +type DraftSectionProps = { + interviewDraftType: GetMyInterviewDraftsInterviewDraftType +} + +export default function DraftSection({ interviewDraftType }: DraftSectionProps) { + const navigate = useNavigate() + const [isModalOpen, setIsModalOpen] = useState(false) + const { data: items = [], isPending } = useGetMyInterviewDrafts( + { interviewDraftType, page: 0, size: 5 }, + { + query: { + select: (response) => (response.result?.content ?? []).map(mapDraftInterviewRow), + }, + }, + ) + const draftType = interviewDraftType === 'LOGGING' ? '기록' : '회고' + + return ( + <> +
+
+ +
+

임시저장한 {draftType}

+ + + + 응시일 + 회사 + 직무 + 면접 유형 + + + + {isPending ? ( + + + 로딩중... + + + ) : items.length === 0 ? ( + + + 임시저장한 {draftType} 데이터가 아직 없어요. + + + ) : ( + items.map( + ({ interviewId, interviewReviewStatus, interviewStartAt, company, jobCategoryName, interviewType }) => ( + navigate(getInterviewNavigationPath(interviewId, interviewReviewStatus))} + > + {interviewStartAt} + {company} + {jobCategoryName} + {INTERVIEW_TYPE_LABEL[interviewType]} + + ), + ) + )} + +
+
+ setIsModalOpen(false)} + interviewDraftType={interviewDraftType} + /> + + ) +} diff --git a/frontend/src/features/dashboard/my-interviews/components/interviews/draft/draft-list-modal/DraftListModal.tsx b/frontend/src/features/dashboard/my-interviews/components/interviews/draft/draft-list-modal/DraftListModal.tsx new file mode 100644 index 000000000..bc2e94f1c --- /dev/null +++ b/frontend/src/features/dashboard/my-interviews/components/interviews/draft/draft-list-modal/DraftListModal.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router' +import type { GetMyInterviewDraftsInterviewDraftType } from '@/apis/generated/refit-api.schemas' +import { getInterviewNavigationPath } from '@/constants/interviewReviewStatusRoutes' +import { INTERVIEW_TYPE_LABEL } from '@/constants/interviews' +import { ArrowLeftIcon, ArrowRightIcon } from '@/designs/assets' +import { Button, Modal, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/designs/components' +import ConfirmModal from '@/designs/components/modal/ConfirmModal' +import { useDraftDelete } from './useDraftDelete' +import { useDraftList } from './useDraftList' + +type DraftListModalProps = { + open: boolean + onClose: () => void + interviewDraftType: GetMyInterviewDraftsInterviewDraftType +} + +export default function DraftListModal({ open, onClose, interviewDraftType }: DraftListModalProps) { + const navigate = useNavigate() + const [isEditMode, setIsEditMode] = useState(false) + + const { page, setPage, visibleRows, totalCount, totalPages, draftType, isPending, markAsRemoved, resetList } = + useDraftList(open, interviewDraftType) + + const { pendingDeleteId, setPendingDeleteId, isDeleting, handleConfirmDelete } = useDraftDelete(markAsRemoved) + + const handleClose = () => { + resetList() + setIsEditMode(false) + onClose() + } + + return ( + <> + + 임시저장한 {draftType} + + 총 {totalCount}개 + +
+ } + > +
+
+ +
+ + + + + 최신순 + 응시일 + 회사 + 직무 + 면접 유형 + {isEditMode && 관리} + + + + {isPending ? ( + + + 로딩중... + + + ) : visibleRows.length === 0 ? ( + + + 임시저장한 {draftType} 데이터가 아직 없어요. + + + ) : ( + visibleRows.map((row, idx) => ( + navigate(getInterviewNavigationPath(row.interviewId, row.interviewReviewStatus)) + : undefined + } + > + {page * 10 + idx + 1} + {row.interviewStartAt} + {row.company} + {row.jobCategoryName} + {INTERVIEW_TYPE_LABEL[row.interviewType]} + {isEditMode && ( + + + + )} + + )) + )} + +
+ + {totalPages > 1 && ( +
+ + + {page + 1} / {totalPages} + + +
+ )} +
+ + + setPendingDeleteId(null)} + title="임시저장 항목을 삭제할까요?" + description={`삭제하면 연결된 면접 일정과\n관련 기록도 함께 삭제돼요`} + hasCancelButton={true} + cancelText="취소" + okText="삭제" + okButtonVariant="fill-orange-500" + okButtonLoading={isDeleting} + onOk={handleConfirmDelete} + /> + + ) +} diff --git a/frontend/src/features/dashboard/my-interviews/components/interviews/draft/draft-list-modal/useDraftDelete.ts b/frontend/src/features/dashboard/my-interviews/components/interviews/draft/draft-list-modal/useDraftDelete.ts new file mode 100644 index 000000000..64b378498 --- /dev/null +++ b/frontend/src/features/dashboard/my-interviews/components/interviews/draft/draft-list-modal/useDraftDelete.ts @@ -0,0 +1,37 @@ +import { useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { useDeleteInterview } from '@/apis/generated/interview-api/interview-api' + +export function useDraftDelete(onDeleteSuccess: (id: number) => void) { + const queryClient = useQueryClient() + const [pendingDeleteId, setPendingDeleteId] = useState(null) + + const { mutate: deleteInterview, isPending: isDeleting } = useDeleteInterview({ + mutation: { + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['/interview/my/draft'] }) + void queryClient.invalidateQueries({ queryKey: ['my-interviews', 'interview-list'] }) + }, + }, + }) + + const handleConfirmDelete = () => { + if (pendingDeleteId === null) return + deleteInterview( + { interviewId: pendingDeleteId }, + { + onSuccess: () => { + onDeleteSuccess(pendingDeleteId) + setPendingDeleteId(null) + }, + }, + ) + } + + return { + pendingDeleteId, + setPendingDeleteId, + isDeleting, + handleConfirmDelete, + } +} diff --git a/frontend/src/features/dashboard/my-interviews/components/interviews/draft/draft-list-modal/useDraftList.ts b/frontend/src/features/dashboard/my-interviews/components/interviews/draft/draft-list-modal/useDraftList.ts new file mode 100644 index 000000000..fbe27f43a --- /dev/null +++ b/frontend/src/features/dashboard/my-interviews/components/interviews/draft/draft-list-modal/useDraftList.ts @@ -0,0 +1,41 @@ +import { useMemo, useState } from 'react' +import { useGetMyInterviewDrafts } from '@/apis/generated/interview-my-api/interview-my-api' +import type { GetMyInterviewDraftsInterviewDraftType } from '@/apis/generated/refit-api.schemas' +import { mapDraftInterviewRow } from '@/features/dashboard/my-interviews/components/interviews/mappers' + +const PAGE_SIZE = 10 + +export function useDraftList(open: boolean, interviewDraftType: GetMyInterviewDraftsInterviewDraftType) { + const [page, setPage] = useState(0) + const [removedIds, setRemovedIds] = useState([]) + + const { data, isPending } = useGetMyInterviewDrafts( + { interviewDraftType, page, size: PAGE_SIZE }, + { query: { enabled: open } }, + ) + + const rows = useMemo(() => (data?.result?.content ?? []).map(mapDraftInterviewRow), [data?.result?.content]) + const visibleRows = useMemo(() => rows.filter((row) => !removedIds.includes(row.interviewId)), [removedIds, rows]) + const totalCount = data?.result?.totalElements ?? 0 + const totalPages = data?.result?.totalPages ?? 1 + const draftType = interviewDraftType === 'LOGGING' ? '기록' : '회고' + + const markAsRemoved = (id: number) => setRemovedIds((prev) => [...prev, id]) + + const resetList = () => { + setPage(0) + setRemovedIds([]) + } + + return { + page, + setPage, + visibleRows, + totalCount, + totalPages, + draftType, + isPending, + markAsRemoved, + resetList, + } +} diff --git a/frontend/src/features/dashboard/my-interviews/components/interviews/index.ts b/frontend/src/features/dashboard/my-interviews/components/interviews/index.ts deleted file mode 100644 index 1489a399e..000000000 --- a/frontend/src/features/dashboard/my-interviews/components/interviews/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as DraftSection } from './DraftSection' -export { default as InterviewCard } from './InterviewCard' -export { default as InterviewListSection } from './InterviewListSection' diff --git a/frontend/src/features/dashboard/my-interviews/components/interviews/list/InterviewListSection.tsx b/frontend/src/features/dashboard/my-interviews/components/interviews/list/InterviewListSection.tsx new file mode 100644 index 000000000..4ff85f761 --- /dev/null +++ b/frontend/src/features/dashboard/my-interviews/components/interviews/list/InterviewListSection.tsx @@ -0,0 +1,39 @@ +import type { InterviewFilter } from '@/types/interview' +import InterviewCard from './interview-card/InterviewCard' +import { useInfiniteInterviewList } from './useInfiniteInterviewList' + +type InterviewListSectionProps = { + filter: InterviewFilter +} + +export default function InterviewListSection({ filter }: InterviewListSectionProps) { + const { items, loadMoreRef, isInitialLoading, isFetchingNext, isPending, emptyMessage } = + useInfiniteInterviewList(filter) + const hasItems = items.length > 0 + const isLoadingMore = hasItems && (isFetchingNext || isPending) + + const renderListContent = () => { + if (isInitialLoading) { + return + } + if (!hasItems) { + return + } + return items.map((item) => ) + } + + return ( +
+
{renderListContent()}
+ {hasItems && ( +
+ {isLoadingMore ? '불러오는 중...' : ''} +
+ )} +
+ ) +} + +function StatusText({ message }: { message: string }) { + return
{message}
+} diff --git a/frontend/src/features/dashboard/my-interviews/components/interviews/InterviewCard.tsx b/frontend/src/features/dashboard/my-interviews/components/interviews/list/interview-card/InterviewCard.tsx similarity index 83% rename from frontend/src/features/dashboard/my-interviews/components/interviews/InterviewCard.tsx rename to frontend/src/features/dashboard/my-interviews/components/interviews/list/interview-card/InterviewCard.tsx index fafcd5e07..f9fd2f47f 100644 --- a/frontend/src/features/dashboard/my-interviews/components/interviews/InterviewCard.tsx +++ b/frontend/src/features/dashboard/my-interviews/components/interviews/list/interview-card/InterviewCard.tsx @@ -1,5 +1,6 @@ import { INTERVIEW_TYPE_LABEL } from '@/constants/interviews' import { Badge, Border } from '@/designs/components' +import { formatDate } from '@/features/_common/utils/date' import { RESULT_LABEL, RESULT_THEME, @@ -27,10 +28,10 @@ export default function InterviewCard({ children, }: InterviewCardProps) { return ( -
+
- {date} + {formatDate(date)} 응시
diff --git a/frontend/src/features/dashboard/my-interviews/components/interviews/list/useInfiniteInterviewList.ts b/frontend/src/features/dashboard/my-interviews/components/interviews/list/useInfiniteInterviewList.ts new file mode 100644 index 000000000..0eb712a87 --- /dev/null +++ b/frontend/src/features/dashboard/my-interviews/components/interviews/list/useInfiniteInterviewList.ts @@ -0,0 +1,97 @@ +import { useEffect, useMemo, useRef } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' +import { searchInterviews } from '@/apis/generated/interview-my-api/interview-my-api' +import type { InterviewSearchRequest } from '@/apis/generated/refit-api.schemas' +import { + mapInterviewCard, + type InterviewCardModel, +} from '@/features/dashboard/my-interviews/components/interviews/mappers' +import type { InterviewFilter } from '@/types/interview' + +const PAGE_SIZE = 9 +const OBSERVER_ROOT_MARGIN = '200px' + +export function useInfiniteInterviewList(filter: InterviewFilter) { + const loadMoreRef = useRef(null) + + const hasFilterCondition = useMemo(() => hasActiveFilterCondition(filter), [filter]) + const searchBody = useMemo(() => toInterviewSearchRequestBody(filter), [filter]) + const sortParam = useMemo(() => (filter.sort ? [filter.sort] : undefined), [filter.sort]) + + const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage, isError } = useInfiniteQuery({ + queryKey: ['my-interviews', 'interview-list', searchBody, sortParam], + initialPageParam: 0, + queryFn: ({ pageParam }) => + searchInterviews(searchBody, { + page: pageParam, + size: PAGE_SIZE, + sort: sortParam, + }), + getNextPageParam: (lastPage, _allPages, lastPageParam) => { + const totalPages = lastPage.result?.totalPages ?? 0 + const nextPage = lastPageParam + 1 + return nextPage < totalPages ? nextPage : undefined + }, + }) + + useEffect(() => { + const target = loadMoreRef.current + if (!target || !hasNextPage || isPending || isFetchingNextPage) return + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) void fetchNextPage() + }, + { rootMargin: OBSERVER_ROOT_MARGIN }, + ) + + observer.observe(target) + return () => observer.disconnect() + }, [fetchNextPage, hasNextPage, isFetchingNextPage, isPending]) + + const items = useMemo(() => toFlatInterviewItems(data?.pages), [data?.pages]) + const emptyMessage = useMemo(() => getEmptyMessage({ isError, hasFilterCondition }), [hasFilterCondition, isError]) + + return { + items, + loadMoreRef, + isInitialLoading: isPending, + isFetchingNext: isFetchingNextPage, + isPending, + hasNextPage: Boolean(hasNextPage), + emptyMessage, + } +} + +const toInterviewSearchRequestBody = (filter: InterviewFilter): InterviewSearchRequest => ({ + keyword: filter.keyword || undefined, + searchFilter: { + // searchFilter 조건 없어도 null/빈배열로 채워서 전송 + interviewType: filter.interviewType, + interviewResultStatus: filter.resultStatus, + startDate: toNullableDate(filter.startDate), + endDate: toNullableDate(filter.endDate), + } as InterviewSearchRequest['searchFilter'], +}) + +const hasActiveFilterCondition = (filter: InterviewFilter) => + filter.keyword.trim().length > 0 || + filter.interviewType.length > 0 || + filter.resultStatus.length > 0 || + filter.startDate.length > 0 || + filter.endDate.length > 0 + +const toFlatInterviewItems = ( + pages: Awaited>[] | undefined, +): InterviewCardModel[] => + (pages ?? []).flatMap((page) => (page.result?.content ?? []).map((item) => mapInterviewCard(item))) + +const getEmptyMessage = ({ isError, hasFilterCondition }: { isError: boolean; hasFilterCondition: boolean }) => { + if (isError) return '면접 목록을 불러오지 못했어요. 잠시 후 다시 시도해 주세요.' + if (hasFilterCondition) return '선택한 검색/필터 조건에 맞는 면접이 없어요. 조건을 바꿔서 다시 확인해보세요.' + return '아직 면접 기록이 없어요. 면접을 등록하고 기록을 모아보세요.' +} + +function toNullableDate(value: string): string | null { + return value.trim() ? value : null +} diff --git a/frontend/src/features/dashboard/my-interviews/components/interviews/mappers.ts b/frontend/src/features/dashboard/my-interviews/components/interviews/mappers.ts new file mode 100644 index 000000000..81e8e5ac8 --- /dev/null +++ b/frontend/src/features/dashboard/my-interviews/components/interviews/mappers.ts @@ -0,0 +1,58 @@ +import type { + InterviewDto, + InterviewSimpleDto, + InterviewSimpleDtoInterviewReviewStatus, +} from '@/apis/generated/refit-api.schemas' +import { formatDate } from '@/features/_common/utils/date' +import type { InterviewResultStatus } from '@/features/dashboard/my-interviews/constants/constants' +import type { InterviewType } from '@/types/interview' + +export type DraftInterviewRowModel = { + interviewId: number + interviewReviewStatus: InterviewSimpleDtoInterviewReviewStatus + interviewStartAt: string + company: string + jobCategoryName: string + interviewType: InterviewType +} + +export type InterviewCardModel = { + interviewId: number + resultStatus: InterviewResultStatus + date: string + company: string + jobRole: string + interviewType: InterviewType +} + +export function mapDraftInterviewRow(item: InterviewSimpleDto): DraftInterviewRowModel { + return { + interviewId: item.interviewId!, + interviewReviewStatus: item.interviewReviewStatus, + interviewStartAt: `${formatDate(item.interviewStartAt)} 응시`, + company: item.companyInfo?.companyName ?? '', + jobCategoryName: item.jobCategoryName ?? '', + interviewType: toInterviewType(item.interviewType), + } +} + +export function mapInterviewCard(item: InterviewDto): InterviewCardModel { + return { + interviewId: item.interviewId, + resultStatus: toResultStatus(item.interviewResultStatus), + date: item.interviewStartAt, + company: item.companyName ?? '', + jobRole: item.jobCategoryName ?? '', + interviewType: toInterviewType(item.interviewType), + } +} + +function toInterviewType(value?: string): InterviewType { + if (!value) return 'FIRST' + return value as InterviewType +} + +function toResultStatus(value?: string): InterviewResultStatus { + if (value === 'PASS' || value === 'FAIL' || value === 'WAIT') return value + return 'WAIT' +} diff --git a/frontend/src/features/dashboard/my-interviews/components/questions/frequent/FrequentQuestionsSection.tsx b/frontend/src/features/dashboard/my-interviews/components/questions/frequent/FrequentQuestionsSection.tsx index e8929563a..eaca3a21a 100644 --- a/frontend/src/features/dashboard/my-interviews/components/questions/frequent/FrequentQuestionsSection.tsx +++ b/frontend/src/features/dashboard/my-interviews/components/questions/frequent/FrequentQuestionsSection.tsx @@ -32,7 +32,9 @@ export default function FrequentQuestionsSection() { return (

{nickname}님이 자주 받은 질문들

-
{DATA_EMPTY_MESSAGE.questions}
+
+ {DATA_EMPTY_MESSAGE.questions} +
) } diff --git a/frontend/src/features/dashboard/my-interviews/components/questions/index.ts b/frontend/src/features/dashboard/my-interviews/components/questions/index.ts deleted file mode 100644 index 3badbeab0..000000000 --- a/frontend/src/features/dashboard/my-interviews/components/questions/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as FrequentQuestionsSection } from './frequent/FrequentQuestionsSection' -export { default as QnaCard } from './list/qna-card/QnaCard' -export { default as QuestionListSection } from './list/QuestionListSection' diff --git a/frontend/src/features/dashboard/my-interviews/components/questions/list/qna-card/QnaCard.tsx b/frontend/src/features/dashboard/my-interviews/components/questions/list/qna-card/QnaCard.tsx index 95f7d29ff..3dd529332 100644 --- a/frontend/src/features/dashboard/my-interviews/components/questions/list/qna-card/QnaCard.tsx +++ b/frontend/src/features/dashboard/my-interviews/components/questions/list/qna-card/QnaCard.tsx @@ -1,4 +1,4 @@ -import InterviewCard from '@/features/dashboard/my-interviews/components/interviews/InterviewCard' +import InterviewCard from '@/features/dashboard/my-interviews/components/interviews/list/interview-card/InterviewCard' import type { InterviewResultStatus } from '@/features/dashboard/my-interviews/constants/constants' import type { InterviewType } from '@/types/interview' diff --git a/frontend/src/features/dashboard/my-interviews/constants/constants.ts b/frontend/src/features/dashboard/my-interviews/constants/constants.ts index f3c340f77..716b8848d 100644 --- a/frontend/src/features/dashboard/my-interviews/constants/constants.ts +++ b/frontend/src/features/dashboard/my-interviews/constants/constants.ts @@ -12,7 +12,7 @@ export const EMPTY_FILTER: InterviewFilter = { resultStatus: [], startDate: '', endDate: '', - sort: 'date-latest', + sort: 'interviewStartAt,desc', } export const RESULT_THEME = { @@ -35,6 +35,13 @@ export const RESULT_STATUS_ITEMS: LabelValueType[] = [ export type InterviewResultStatus = 'PASS' | 'WAIT' | 'FAIL' +export const INTERVIEW_SORT_OPTIONS = [ + { label: '면접 일시 최신순', value: 'interviewStartAt,desc' }, + { label: '면접 일시 오래된 순', value: 'interviewStartAt,asc' }, + { label: '최신 업데이트순', value: 'updatedAt,desc' }, + { label: '가나다순', value: 'companyName,asc' }, +] as const + export const EMPTY_QUESTION_FILTER: QuestionFilter = { keyword: '', sort: 'interviewStartAt,desc', @@ -59,6 +66,6 @@ export const STAR_LEVEL_OPTIONS: { label: string; value: StarLevel }[] = [ ] export const DATA_EMPTY_MESSAGE = { - interviews: '아직 면접 기록이 없어요. 면접을 보고 결과를 등록해서 면접 기록을 모아보세요.', + interviews: '아직 면접 기록이 없어요. 면접을 등록하고 기록을 모아보세요.', questions: '아직 질문 데이터가 없어요. 면접 기록을 진행해서 질문과 답변을 모아보세요.', } diff --git a/frontend/src/features/dashboard/my-interviews/example.ts b/frontend/src/features/dashboard/my-interviews/example.ts deleted file mode 100644 index f6418cb95..000000000 --- a/frontend/src/features/dashboard/my-interviews/example.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { InterviewType } from '@/types/interview' - -export type InterviewItemType = { - resultStatus: 'WAIT' | 'PASS' | 'FAIL' - date: string - company: string - jobRole: string - interviewType: InterviewType -} - -export type QuestionPreviewType = InterviewItemType & { - question: string -} - -export type QnaItemType = InterviewItemType & { - question: string - answer: string -} - -export type QuestionRankType = { - categoryId: number - categoryName: string - frequentCount: number -} - -export type DraftItemType = { - interviewId: number - interviewStartAt: string - company: string - jobCategoryName: string - interviewType: InterviewType -} - -export const MOCK_DRAFTS: DraftItemType[] = [ - { - interviewId: 1, - interviewStartAt: '2025. 11. 19 응시', - company: '현대자동차', - jobCategoryName: '서비스 기획', - interviewType: 'FIRST', - }, - { - interviewId: 2, - interviewStartAt: '2025. 11. 19 응시', - company: '현대자동차', - jobCategoryName: '서비스 기획', - interviewType: 'FIRST', - }, - { - interviewId: 3, - interviewStartAt: '2025. 11. 19 응시', - company: '현대자동차', - jobCategoryName: '서비스 기획', - interviewType: 'FIRST', - }, - { - interviewId: 4, - interviewStartAt: '2025. 11. 19 응시', - company: '현대자동차', - jobCategoryName: '서비스 기획', - interviewType: 'FIRST', - }, - { - interviewId: 5, - interviewStartAt: '2025. 11. 19 응시', - company: '현대자동차', - jobCategoryName: '서비스 기획', - interviewType: 'FIRST', - }, -] - -export const MOCK_COMPLETED: InterviewItemType[] = [ - { - resultStatus: 'PASS', - date: '2025. 11. 29 응시', - company: '현대자동차', - jobRole: '데이터 사이언티스트', - interviewType: 'CULTURE_FIT', - }, - { - resultStatus: 'WAIT', - date: '2025. 11. 29 응시', - company: '현대자동차', - jobRole: '데이터 사이언티스트', - interviewType: 'CULTURE_FIT', - }, - { - resultStatus: 'FAIL', - date: '2025. 11. 29 응시', - company: '현대자동차', - jobRole: '데이터 사이언티스트', - interviewType: 'CULTURE_FIT', - }, - { - resultStatus: 'PASS', - date: '2025. 11. 29 응시', - company: '현대자동차', - jobRole: '데이터 사이언티스트', - interviewType: 'CULTURE_FIT', - }, - { - resultStatus: 'FAIL', - date: '2025. 11. 29 응시', - company: '현대자동차', - jobRole: '데이터 사이언티스트', - interviewType: 'CULTURE_FIT', - }, - { - resultStatus: 'WAIT', - date: '2025. 11. 29 응시', - company: '현대자동차', - jobRole: '데이터 사이언티스트', - interviewType: 'CULTURE_FIT', - }, - { - resultStatus: 'WAIT', - date: '2025. 11. 29 응시', - company: '현대자동차', - jobRole: '데이터 사이언티스트', - interviewType: 'CULTURE_FIT', - }, - { - resultStatus: 'PASS', - date: '2025. 11. 29 응시', - company: '현대자동차', - jobRole: '데이터 사이언티스트', - interviewType: 'CULTURE_FIT', - }, -] - -export const MOCK_QNA: QnaItemType[] = [ - { - resultStatus: 'PASS', - date: '2025. 11. 29 응시', - company: '현대자동차', - jobRole: '데이터 사이언티스트', - interviewType: 'CULTURE_FIT', - question: '팀에서 리더 역할을 맡았던 경험이 있나요?', - answer: '대학교 졸업 프로젝트에서 팀장을 맡아 5명의 팀원들과 함께 AI 기반 추천 시스템을 개발했습니다.', - }, - { - resultStatus: 'WAIT', - date: '2025. 11. 29 응시', - company: '카카오', - jobRole: '프론트엔드 개발자', - interviewType: 'FIRST', - question: '가장 어려웠던 기술적 문제와 해결 과정을 설명해주세요.', - answer: - '실시간 채팅 서비스에서 WebSocket 연결이 끊기는 문제를 해결하기 위해 재연결 로직과 메시지 큐를 구현했습니다.', - }, - { - resultStatus: 'FAIL', - date: '2025. 11. 25 응시', - company: '네이버', - jobRole: '서비스 기획', - interviewType: 'BEHAVIORAL', - question: '협업 과정에서 갈등이 생겼을 때 어떻게 해결하시나요?', - answer: '먼저 상대방의 의견을 충분히 경청한 후, 공통점을 찾아 합의점을 도출하려고 노력합니다.', - }, - { - resultStatus: 'PASS', - date: '2025. 11. 20 응시', - company: '토스', - jobRole: '백엔드 개발자', - interviewType: 'TECHNICAL', - question: 'RESTful API 설계 원칙에 대해 설명해주세요.', - answer: 'REST는 자원을 URI로 표현하고, HTTP 메서드를 통해 자원에 대한 행위를 정의하는 아키텍처 스타일입니다.', - }, -] diff --git a/frontend/src/pages/dashboard/my-interviews/page.tsx b/frontend/src/pages/dashboard/my-interviews/page.tsx index 60944acf1..84d132e22 100644 --- a/frontend/src/pages/dashboard/my-interviews/page.tsx +++ b/frontend/src/pages/dashboard/my-interviews/page.tsx @@ -1,22 +1,13 @@ import { useState } from 'react' import { FileSaveIcon } from '@/designs/assets' import TabBar from '@/designs/components/tab' -import { FilterSortControls, SearchBar, SearchResultBar } from '@/features/dashboard/my-interviews/components/filter' -import { DraftSection, InterviewListSection } from '@/features/dashboard/my-interviews/components/interviews' +import InterviewsTab from '@/features/dashboard/my-interviews/components/interviews/InterviewsTab' import QuestionsTab from '@/features/dashboard/my-interviews/components/questions/QuestionsTab' -import { TAB_ITEMS, EMPTY_FILTER } from '@/features/dashboard/my-interviews/constants/constants' -import type { InterviewFilter } from '@/types/interview' +import { TAB_ITEMS } from '@/features/dashboard/my-interviews/constants/constants' export default function MyInterviewsPage() { const [activeTab, setActiveTab] = useState<'interviews' | 'questions'>('interviews') - { - /* TODO: interview 탭 컴포넌트 분리 */ - } - const [interviewFilter, setInterviewFilter] = useState(EMPTY_FILTER) - - const isInterviewSearching = interviewFilter.keyword.length > 0 - const handleTabChange = (value: string) => { if (value !== 'interviews' && value !== 'questions') return setActiveTab(value) @@ -32,41 +23,7 @@ export default function MyInterviewsPage() {
- {/* TODO: interview 탭 컴포넌트 분리 */} - {activeTab === 'interviews' && ( - <> -
- setInterviewFilter((prev) => ({ ...prev, keyword }))} - /> -
- {!isInterviewSearching && ( -
-

임시저장 항목

-
- - -
-
- )} -
-
- {isInterviewSearching ? ( - setInterviewFilter((prev) => ({ ...prev, keyword: '' }))} - /> - ) : ( -

내가 복기 완료한 면접

- )} - -
- -
- - )} - + {activeTab === 'interviews' && } {activeTab === 'questions' && }
diff --git a/frontend/src/types/interview.d.ts b/frontend/src/types/interview.d.ts index 54092a06c..27f5f87b4 100644 --- a/frontend/src/types/interview.d.ts +++ b/frontend/src/types/interview.d.ts @@ -1,4 +1,5 @@ import type { + InterviewSearchRequest, QnaSetSearchRequest, QnaSearchFilterAInclusionLevelsItem, QnaSearchFilterRInclusionLevelsItem, @@ -56,8 +57,8 @@ type InterviewFullType = { export type InterviewFilter = { keyword: string - interviewType: string[] - resultStatus: string[] + interviewType: NonNullable + resultStatus: NonNullable startDate: string endDate: string sort: string