From 0c1c33a7b85b74635af3de7dab3d177a3787825b Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:57:49 +0900 Subject: [PATCH 01/15] =?UTF-8?q?fix=20:=20=EC=95=8C=EB=B0=94=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=96=89=EC=A0=95=EB=8F=99=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/job-lookup-map/common/Albabox.tsx | 6 +++--- src/features/job-lookup-map/lib/postingToAlbaboxProps.ts | 2 +- src/features/job-lookup-map/types/posting.ts | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/features/job-lookup-map/common/Albabox.tsx b/src/features/job-lookup-map/common/Albabox.tsx index 70809a5c..8228977f 100644 --- a/src/features/job-lookup-map/common/Albabox.tsx +++ b/src/features/job-lookup-map/common/Albabox.tsx @@ -10,7 +10,7 @@ export type AlbaboxProps = { wageAmount: string timeRange: string workDays: string - distance: string + town: string postedAgo: string saved: boolean likeCount?: string @@ -24,7 +24,7 @@ export function Albabox({ wageAmount, timeRange, workDays, - distance, + town, postedAgo, saved, likeCount, @@ -52,7 +52,7 @@ export function Albabox({

{storeName}

- {distance} + {town} · {postedAgo}
diff --git a/src/features/job-lookup-map/lib/postingToAlbaboxProps.ts b/src/features/job-lookup-map/lib/postingToAlbaboxProps.ts index f491ffff..5b22a0de 100644 --- a/src/features/job-lookup-map/lib/postingToAlbaboxProps.ts +++ b/src/features/job-lookup-map/lib/postingToAlbaboxProps.ts @@ -86,7 +86,7 @@ export function postingToAlbaboxProps( wageAmount: p.payAmount.toLocaleString('ko-KR'), timeRange, workDays, - distance: '-', + town: p.workspace.town?.trim() || '-', postedAgo: formatPostedAgo(p.createdAt), saved: p.scrapped, } diff --git a/src/features/job-lookup-map/types/posting.ts b/src/features/job-lookup-map/types/posting.ts index a867d4f2..3230efba 100644 --- a/src/features/job-lookup-map/types/posting.ts +++ b/src/features/job-lookup-map/types/posting.ts @@ -55,6 +55,7 @@ export interface Workspace { latitude: number longitude: number fullAddress: string + town: string } export interface ApplyPostingRequest { From ccfbeb8df6ee0c7390dd11e4ed2cfa228a4eb35f Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:04:12 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat=20:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=EB=B3=84=20=ED=8C=9D=EC=97=85=EC=B0=BD=20UI=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/job-lookup-map/api/posting.ts | 103 +++++- .../common/AlbaFindCategoryBar.tsx | 241 ++++++++++---- .../common/FilterDrawerShell.tsx | 133 ++++++++ .../common/RegionSelectDrawer.tsx | 294 ++++++++++++++++++ .../common/SalarySelectDrawer.tsx | 173 +++++++++++ .../common/SortSelectDrawer.tsx | 113 +++++++ .../job-lookup-map/hooks/usePosting.ts | 12 +- .../hooks/usePostingFilterOptions.ts | 23 ++ .../job-lookup-map/lib/postingFilters.ts | 135 ++++++++ .../job-lookup-map/lib/regionOptions.ts | 47 +++ src/features/job-lookup-map/types/posting.ts | 15 +- src/pages/user/job-lookup-map/index.tsx | 78 ++++- 12 files changed, 1269 insertions(+), 98 deletions(-) create mode 100644 src/features/job-lookup-map/common/FilterDrawerShell.tsx create mode 100644 src/features/job-lookup-map/common/RegionSelectDrawer.tsx create mode 100644 src/features/job-lookup-map/common/SalarySelectDrawer.tsx create mode 100644 src/features/job-lookup-map/common/SortSelectDrawer.tsx create mode 100644 src/features/job-lookup-map/hooks/usePostingFilterOptions.ts create mode 100644 src/features/job-lookup-map/lib/postingFilters.ts create mode 100644 src/features/job-lookup-map/lib/regionOptions.ts diff --git a/src/features/job-lookup-map/api/posting.ts b/src/features/job-lookup-map/api/posting.ts index 3a84faf3..bea180e7 100644 --- a/src/features/job-lookup-map/api/posting.ts +++ b/src/features/job-lookup-map/api/posting.ts @@ -2,15 +2,18 @@ import axiosInstance from '@/shared/lib/axiosInstance' import type { CommonApiResponse } from '@/shared/types/common' import type { ApplyPostingRequest, - PostingListResponse, PostingDetailResponse, + PostingFilterOptions, + PostingListResponse, } from '@/features/job-lookup-map/types/posting' +import type { PostingsListFilters } from '@/features/job-lookup-map/lib/postingFilters' + export type FetchPostingsParams = { pageSize: number cursor?: string searchKeyword?: string -} +} & PostingsListFilters function isCommonApiEnvelope( value: unknown @@ -23,6 +26,52 @@ function isCommonApiEnvelope( ) } +function normalizePostingListResponse(value: unknown): PostingListResponse { + const payload = isCommonApiEnvelope(value) ? value.data : value + if (payload === null || typeof payload !== 'object') { + throw new Error('공고 목록을 불러오지 못했습니다.') + } + + const record = payload as Record + const data = Array.isArray(record.data) + ? (record.data as PostingListResponse['data']) + : [] + const pageRaw = record.page + + if (pageRaw !== null && typeof pageRaw === 'object') { + const page = pageRaw as Record + const cursor = page.cursor + return { + data, + page: { + cursor: + typeof cursor === 'string' + ? cursor + : cursor == null + ? null + : String(cursor), + pageSize: + typeof page.pageSize === 'number' ? page.pageSize : data.length, + totalCount: + typeof page.totalCount === 'number' ? page.totalCount : data.length, + }, + } + } + + return { + data, + page: { + cursor: null, + pageSize: data.length, + totalCount: data.length, + }, + } +} + +function unwrapPostingListBody(body: unknown): PostingListResponse { + return normalizePostingListResponse(body) +} + function isPostingDetailResponse( value: unknown ): value is PostingDetailResponse { @@ -63,20 +112,34 @@ function unwrapPostingDetailBody(body: unknown): PostingDetailResponse { export async function fetchPostings( params: FetchPostingsParams ): Promise { - const response = await axiosInstance.get( - '/app/postings', - { - params: { - pageSize: params.pageSize, - ...(params.cursor !== undefined && - params.cursor !== '' && { cursor: params.cursor }), - ...(params.searchKeyword?.trim() && { - searchKeyword: params.searchKeyword.trim(), - }), - }, - } - ) - return response.data + const { + pageSize, + cursor, + searchKeyword, + province, + district, + town, + minPayAmount, + maxPayAmount, + payAmountSort, + } = params + + const response = await axiosInstance.get('/app/postings', { + params: { + pageSize, + ...(cursor !== undefined && cursor !== '' && { cursor }), + ...(searchKeyword?.trim() && { + searchKeyword: searchKeyword.trim(), + }), + ...(province && { province }), + ...(district && { district }), + ...(town && { town }), + ...(minPayAmount != null && { minPayAmount }), + ...(maxPayAmount != null && { maxPayAmount }), + ...(payAmountSort != null && { payAmountSort }), + }, + }) + return unwrapPostingListBody(response.data) } export async function fetchPostingDetail( @@ -98,3 +161,11 @@ export async function applyPosting( body ) } + +/** GET /app/postings/filter-options — 공고 목록 필터 옵션 조회 */ +export async function fetchPostingFilterOptions(): Promise { + const response = await axiosInstance.get< + CommonApiResponse + >('/app/postings/filter-options') + return response.data.data +} diff --git a/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx b/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx index a9acf51a..fe088ca0 100644 --- a/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx +++ b/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx @@ -1,4 +1,23 @@ +import { useState } from 'react' import ChevrondownIcon from '@/assets/icons/job-lookup-map/Chevrondown.svg?react' +import { RegionSelectDrawer } from '@/features/job-lookup-map/common/RegionSelectDrawer' +import { SalarySelectDrawer } from '@/features/job-lookup-map/common/SalarySelectDrawer' +import { SortSelectDrawer } from '@/features/job-lookup-map/common/SortSelectDrawer' +import { usePostingFilterOptions } from '@/features/job-lookup-map/hooks/usePostingFilterOptions' +import { + DEFAULT_SORT_VALUE, + EMPTY_SALARY_FILTER, + formatSalaryChipLabel, + formatSortChipLabel, + isSalaryFilterApplied, + isSortFilterApplied, + type SalaryFilterSelection, +} from '@/features/job-lookup-map/lib/postingFilters' +import { + EMPTY_REGION_SELECTION, + formatRegionLabel, + type RegionSelection, +} from '@/features/job-lookup-map/lib/regionOptions' export type AlbaFindMode = 'nearby' | 'region' @@ -7,94 +26,178 @@ export type AlbaFindFilterId = 'sort' | 'distance' | 'salary' type AlbaFindCategoryBarProps = { mode: AlbaFindMode onModeChange: (mode: AlbaFindMode) => void - activeFilter: AlbaFindFilterId - onFilterChange: (id: AlbaFindFilterId) => void + regionSelection?: RegionSelection + onRegionChange?: (selection: RegionSelection) => void + sortValue?: string + onSortChange?: (value: string) => void + salaryFilter?: SalaryFilterSelection + onSalaryChange?: (selection: SalaryFilterSelection) => void } -const NEARBY_FILTER_ITEMS: { id: AlbaFindFilterId; label: string }[] = [ - { id: 'sort', label: '최신순' }, - { id: 'distance', label: '거리' }, - { id: 'salary', label: '급여' }, +const FILTER_ITEMS: { id: AlbaFindFilterId; label: string }[] = [ + { id: 'sort', label: '지역 선택' }, + { id: 'distance', label: '최신순' }, + { id: 'salary', label: '전체' }, ] -const REGION_FILTER_ITEMS: { id: AlbaFindFilterId; label: string }[] = [ - { id: 'sort', label: '최신순' }, - { id: 'distance', label: '서울' }, - { id: 'salary', label: '전체' }, +const FALLBACK_SORT_OPTIONS = [ + { value: 'LATEST', description: '최신순' }, + { value: 'PAY_AMOUNT', description: '급여순' }, ] -function getFilterItems(mode: AlbaFindMode) { - return mode === 'region' ? REGION_FILTER_ITEMS : NEARBY_FILTER_ITEMS +function getFilterItems( + regionSelection: RegionSelection, + sortValue: string, + sortOptions: { value: string; description: string }[], + salaryFilter: SalaryFilterSelection +) { + return FILTER_ITEMS.map(item => { + if (item.id === 'sort') { + return { ...item, label: formatRegionLabel(regionSelection) } + } + if (item.id === 'distance') { + return { + ...item, + label: formatSortChipLabel(sortValue, sortOptions), + } + } + return { ...item, label: formatSalaryChipLabel(salaryFilter) } + }) } export function AlbaFindCategoryBar({ mode, onModeChange, - activeFilter, - onFilterChange, + regionSelection = EMPTY_REGION_SELECTION, + onRegionChange, + sortValue = DEFAULT_SORT_VALUE, + onSortChange, + salaryFilter = EMPTY_SALARY_FILTER, + onSalaryChange, }: AlbaFindCategoryBarProps) { - const filterItems = getFilterItems(mode) + const { sortOptions } = usePostingFilterOptions() + const resolvedSortOptions = + sortOptions.length > 0 ? sortOptions : FALLBACK_SORT_OPTIONS + + const [isRegionDrawerOpen, setIsRegionDrawerOpen] = useState(false) + const [isSortDrawerOpen, setIsSortDrawerOpen] = useState(false) + const [isSalaryDrawerOpen, setIsSalaryDrawerOpen] = useState(false) + + const filterItems = getFilterItems( + regionSelection, + sortValue, + resolvedSortOptions, + salaryFilter + ) + const regionLabel = formatRegionLabel(regionSelection) + const hasRegionSelected = regionLabel !== '지역 선택' + const hasSortSelected = isSortFilterApplied(sortValue) + const hasSalarySelected = isSalaryFilterApplied(salaryFilter) + + const handleFilterClick = (id: AlbaFindFilterId) => { + if (id === 'sort') { + setIsRegionDrawerOpen(true) + return + } + if (id === 'distance') { + setIsSortDrawerOpen(true) + return + } + if (id === 'salary') { + setIsSalaryDrawerOpen(true) + return + } + } return ( -
-
- - -
+ + +
-
- {filterItems.map(({ id, label }, index) => { - const active = activeFilter === id - const showChevron = mode === 'nearby' || id === 'sort' - return ( -
- {index === 1 ? ( -
- ) : null} - -
- ) - })} + +
+ ) + })} +
- + + onRegionChange?.(selection)} + /> + onSortChange?.(value)} + /> + onSalaryChange?.(selection)} + /> + ) } diff --git a/src/features/job-lookup-map/common/FilterDrawerShell.tsx b/src/features/job-lookup-map/common/FilterDrawerShell.tsx new file mode 100644 index 00000000..8a2d1e19 --- /dev/null +++ b/src/features/job-lookup-map/common/FilterDrawerShell.tsx @@ -0,0 +1,133 @@ +import type { ReactNode } from 'react' +import { Drawer } from 'vaul' + +function CloseIcon({ className }: { className?: string }) { + return ( + + + + ) +} + +type FilterDrawerShellProps = { + open: boolean + onOpenChange: (open: boolean) => void + title: string + children: ReactNode + footer?: ReactNode | null +} + +export function FilterDrawerShell({ + open, + onOpenChange, + title, + children, + footer, +}: FilterDrawerShellProps) { + return ( + + + + +
+ {open ? ( + <> +
+ +
+ + {title} + + +
+ +
{children}
+ + {footer ?? null} + + ) : null} +
+ + + + ) +} + +export function FilterDrawerApplyFooter({ + onApply, + disabled = false, + showReset = false, + onReset, +}: { + onApply: () => void + disabled?: boolean + showReset?: boolean + onReset?: () => void +}) { + if (showReset) { + return ( +
+ + +
+ ) + } + + return ( +
+ +
+ ) +} + +export function RadioIndicator({ selected }: { selected: boolean }) { + return ( + + {selected ? : null} + + ) +} diff --git a/src/features/job-lookup-map/common/RegionSelectDrawer.tsx b/src/features/job-lookup-map/common/RegionSelectDrawer.tsx new file mode 100644 index 00000000..2b72cf56 --- /dev/null +++ b/src/features/job-lookup-map/common/RegionSelectDrawer.tsx @@ -0,0 +1,294 @@ +import { useMemo, useState } from 'react' +import { Drawer } from 'vaul' + +import { ChevronRightIcon } from '@/assets/icons/ChevronRightIcon' +import { usePostingFilterOptions } from '@/features/job-lookup-map/hooks/usePostingFilterOptions' +import { + EMPTY_REGION_SELECTION, + REGION_STEPS, + getRegionOptionsForStep, + isRegionSelectionComplete, + type RegionSelection, + type RegionStep, +} from '@/features/job-lookup-map/lib/regionOptions' + +type RegionSelectDrawerProps = { + open: boolean + onOpenChange: (open: boolean) => void + value?: RegionSelection + onApply: (selection: RegionSelection) => void +} + +function CloseIcon({ className }: { className?: string }) { + return ( + + + + ) +} + +function CheckIcon({ className }: { className?: string }) { + return ( + + + + ) +} + +function RegionSelectDrawerBody({ + value, + onOpenChange, + onApply, +}: { + value: RegionSelection + onOpenChange: (open: boolean) => void + onApply: (selection: RegionSelection) => void +}) { + const { provinces, districts, towns, isLoading, isError } = + usePostingFilterOptions() + + const [draft, setDraft] = useState(value) + const [step, setStep] = useState('sido') + + const options = useMemo( + () => + getRegionOptionsForStep(step, { + provinces, + districts, + towns, + }), + [step, provinces, districts, towns] + ) + + const handleSelect = (option: string) => { + if (step === 'sido') { + const next: RegionSelection = { + sido: option, + sigungu: null, + dong: null, + } + setDraft(next) + if (option === '전국(전체)') return + setStep('sigungu') + return + } + + if (step === 'sigungu') { + const next: RegionSelection = { + ...draft, + sigungu: option, + dong: null, + } + setDraft(next) + if (option === '전체') return + setStep('dong') + return + } + + setDraft({ ...draft, dong: option }) + } + + const handleReset = () => { + setDraft(EMPTY_REGION_SELECTION) + setStep('sido') + } + + const handleApply = () => { + onApply(draft) + onOpenChange(false) + } + + const canGoToStep = (target: RegionStep) => { + if (target === 'sido') return true + if (target === 'sigungu') { + return draft.sido != null && draft.sido !== '전국(전체)' + } + return ( + draft.sido != null && + draft.sido !== '전국(전체)' && + draft.sigungu != null && + draft.sigungu !== '전체' + ) + } + + const selectedForStep = + step === 'sido' + ? draft.sido + : step === 'sigungu' + ? draft.sigungu + : draft.dong + + const canApply = isRegionSelectionComplete(draft) + + return ( + <> +
+ +
+ + 지역 선택 + + +
+ +
+ {REGION_STEPS.map((item, index) => { + const active = step === item.key + const enabled = canGoToStep(item.key) + return ( +
+ {index > 0 ? ( + + ) : null} + +
+ ) + })} +
+ +
+ {isLoading ? ( +

+ 지역 정보를 불러오는 중… +

+ ) : isError ? ( +

+ 지역 정보를 불러오지 못했습니다. +

+ ) : options.length === 0 ? ( +

+ 선택 가능한 지역이 없습니다. +

+ ) : ( +
    + {options.map(option => { + const selected = selectedForStep === option + return ( +
  • + +
  • + ) + })} +
+ )} +
+ +
+ + +
+ + ) +} + +export function RegionSelectDrawer({ + open, + onOpenChange, + value = EMPTY_REGION_SELECTION, + onApply, +}: RegionSelectDrawerProps) { + return ( + + + + +
+ {open ? ( + + ) : null} +
+
+
+
+ ) +} diff --git a/src/features/job-lookup-map/common/SalarySelectDrawer.tsx b/src/features/job-lookup-map/common/SalarySelectDrawer.tsx new file mode 100644 index 00000000..a7e57ed0 --- /dev/null +++ b/src/features/job-lookup-map/common/SalarySelectDrawer.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react' + +import { + FilterDrawerApplyFooter, + FilterDrawerShell, +} from '@/features/job-lookup-map/common/FilterDrawerShell' +import { + EMPTY_SALARY_FILTER, + SALARY_PRESETS, + formatSalaryInput, + parseSalaryInput, + type SalaryFilterSelection, + type SalaryPreset, +} from '@/features/job-lookup-map/lib/postingFilters' + +type SalarySelectDrawerProps = { + open: boolean + onOpenChange: (open: boolean) => void + value?: SalaryFilterSelection + onApply: (selection: SalaryFilterSelection) => void +} + +function SalarySelectDrawerBody({ + value, + onOpenChange, + onApply, +}: { + value: SalaryFilterSelection + onOpenChange: (open: boolean) => void + onApply: (selection: SalaryFilterSelection) => void +}) { + const [draft, setDraft] = useState(value) + const [minInput, setMinInput] = useState(formatSalaryInput(value.min)) + const [maxInput, setMaxInput] = useState(formatSalaryInput(value.max)) + + const handlePresetSelect = (preset: SalaryPreset, min: number | null) => { + setDraft({ preset, min, max: null }) + setMinInput(formatSalaryInput(min)) + setMaxInput('') + } + + const handleMinChange = (raw: string) => { + const min = parseSalaryInput(raw) + setMinInput(min != null ? formatSalaryInput(min) : '') + setDraft(prev => ({ + preset: 'custom', + min, + max: prev.max, + })) + } + + const handleMaxChange = (raw: string) => { + const max = parseSalaryInput(raw) + setMaxInput(max != null ? formatSalaryInput(max) : '') + setDraft(prev => ({ + preset: 'custom', + min: prev.min, + max, + })) + } + + const handleReset = () => { + setDraft(EMPTY_SALARY_FILTER) + setMinInput('') + setMaxInput('') + } + + const handleApply = () => { + onApply(draft) + onOpenChange(false) + } + + return ( + <> +
+
+ {SALARY_PRESETS.map(({ preset, label, min }) => { + const selected = draft.preset === preset + return ( + + ) + })} +
+ +

+ 직접 입력 (min / max) +

+ +
+ + ~ + +
+
+ + + + ) +} + +export function SalarySelectDrawer({ + open, + onOpenChange, + value = EMPTY_SALARY_FILTER, + onApply, +}: SalarySelectDrawerProps) { + return ( + + {open ? ( + + ) : null} + + ) +} diff --git a/src/features/job-lookup-map/common/SortSelectDrawer.tsx b/src/features/job-lookup-map/common/SortSelectDrawer.tsx new file mode 100644 index 00000000..afdb7f48 --- /dev/null +++ b/src/features/job-lookup-map/common/SortSelectDrawer.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react' + +import { + FilterDrawerApplyFooter, + FilterDrawerShell, + RadioIndicator, +} from '@/features/job-lookup-map/common/FilterDrawerShell' +import { usePostingFilterOptions } from '@/features/job-lookup-map/hooks/usePostingFilterOptions' +import { + DEFAULT_SORT_VALUE, + formatSortOptionLabel, +} from '@/features/job-lookup-map/lib/postingFilters' + +const FALLBACK_SORT_OPTIONS = [ + { value: 'LATEST', description: '최신순' }, + { value: 'PAY_AMOUNT', description: '급여순' }, +] as const + +type SortSelectDrawerProps = { + open: boolean + onOpenChange: (open: boolean) => void + value?: string + onApply: (value: string) => void +} + +function SortSelectDrawerBody({ + value, + onOpenChange, + onApply, +}: { + value: string + onOpenChange: (open: boolean) => void + onApply: (value: string) => void +}) { + const { sortOptions, isLoading, isError } = usePostingFilterOptions() + const options = + sortOptions.length > 0 ? sortOptions : [...FALLBACK_SORT_OPTIONS] + const [draft, setDraft] = useState(value) + + const handleApply = () => { + onApply(draft) + onOpenChange(false) + } + + return ( + <> +
+ {isLoading ? ( +

+ 정렬 옵션을 불러오는 중… +

+ ) : isError ? ( +

+ 정렬 옵션을 불러오지 못했습니다. +

+ ) : ( +
    + {options.map(option => { + const selected = draft === option.value + return ( +
  • + +
  • + ) + })} +
+ )} +
+ + + + ) +} + +export function SortSelectDrawer({ + open, + onOpenChange, + value = DEFAULT_SORT_VALUE, + onApply, +}: SortSelectDrawerProps) { + return ( + + {open ? ( + + ) : null} + + ) +} diff --git a/src/features/job-lookup-map/hooks/usePosting.ts b/src/features/job-lookup-map/hooks/usePosting.ts index c728a514..542f838b 100644 --- a/src/features/job-lookup-map/hooks/usePosting.ts +++ b/src/features/job-lookup-map/hooks/usePosting.ts @@ -1,28 +1,33 @@ import { useMemo } from 'react' import { useInfiniteQuery } from '@tanstack/react-query' import { fetchPostings } from '@/features/job-lookup-map/api/posting' +import type { PostingsListFilters } from '@/features/job-lookup-map/lib/postingFilters' import type { Posting } from '@/features/job-lookup-map/types/posting' const PAGE_SIZE = 10 -export function usePostings() { +export function usePostings(filters?: PostingsListFilters) { + const listFilters = useMemo(() => filters ?? {}, [filters]) + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, + isFetching, isError, refetch, } = useInfiniteQuery({ - queryKey: ['jobLookupMap', 'postings', PAGE_SIZE] as const, + queryKey: ['jobLookupMap', 'postings', PAGE_SIZE, listFilters] as const, queryFn: ({ pageParam }) => fetchPostings({ pageSize: PAGE_SIZE, cursor: pageParam as string | undefined, + ...listFilters, }), initialPageParam: undefined as string | undefined, - getNextPageParam: lastPage => lastPage.page.cursor || undefined, + getNextPageParam: lastPage => lastPage.page.cursor ?? undefined, }) const postings = useMemo( @@ -39,6 +44,7 @@ export function usePostings() { hasNextPage: Boolean(hasNextPage), isFetchingNextPage, isLoading: isPending, + isFetching, isError, refetch, } diff --git a/src/features/job-lookup-map/hooks/usePostingFilterOptions.ts b/src/features/job-lookup-map/hooks/usePostingFilterOptions.ts new file mode 100644 index 00000000..44d4d5ec --- /dev/null +++ b/src/features/job-lookup-map/hooks/usePostingFilterOptions.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query' + +import { fetchPostingFilterOptions } from '@/features/job-lookup-map/api/posting' + +export function usePostingFilterOptions(enabled = true) { + const { data, isPending, isError, isFetching, refetch } = useQuery({ + queryKey: ['jobLookupMap', 'filterOptions'] as const, + queryFn: fetchPostingFilterOptions, + enabled, + }) + + return { + filterOptions: data, + provinces: data?.provinces ?? [], + districts: data?.districts ?? [], + towns: data?.towns ?? [], + sortOptions: data?.sortOptions ?? [], + isLoading: isPending, + isFetching, + isError, + refetch, + } +} diff --git a/src/features/job-lookup-map/lib/postingFilters.ts b/src/features/job-lookup-map/lib/postingFilters.ts new file mode 100644 index 00000000..b592bda1 --- /dev/null +++ b/src/features/job-lookup-map/lib/postingFilters.ts @@ -0,0 +1,135 @@ +import type { RegionSelection } from '@/features/job-lookup-map/lib/regionOptions' +import { isRegionSelectionComplete } from '@/features/job-lookup-map/lib/regionOptions' + +export type AlbaFindMode = 'nearby' | 'region' + +export type SalaryPreset = 'all' | '12000' | '15000' | 'custom' + +export type SalaryFilterSelection = { + preset: SalaryPreset + min: number | null + max: number | null +} + +export const EMPTY_SALARY_FILTER: SalaryFilterSelection = { + preset: 'all', + min: null, + max: null, +} + +export const DEFAULT_SORT_VALUE = 'LATEST' + +export function formatSortOptionLabel(option: { + value: string + description: string +}): string { + if (option.value === 'LATEST') return '최신순' + if (option.value === 'PAY_AMOUNT') return '급여순' + return option.description +} + +export const SALARY_PRESETS: { + preset: SalaryPreset + label: string + min: number | null +}[] = [ + { preset: 'all', label: '전체', min: null }, + { preset: '12000', label: '1.2만원 이상', min: 12000 }, + { preset: '15000', label: '1.5만원 이상', min: 15000 }, +] + +export function formatSalaryChipLabel( + selection: SalaryFilterSelection +): string { + if (selection.preset === '12000') return '1.2만원 이상' + if (selection.preset === '15000') return '1.5만원 이상' + if (selection.preset === 'custom') { + if (selection.min != null && selection.max != null) { + return `${selection.min.toLocaleString('ko-KR')}~${selection.max.toLocaleString('ko-KR')}원` + } + if (selection.min != null) { + return `${selection.min.toLocaleString('ko-KR')}원 이상` + } + if (selection.max != null) { + return `${selection.max.toLocaleString('ko-KR')}원 이하` + } + } + return '전체' +} + +export function isSalaryFilterApplied( + selection: SalaryFilterSelection +): boolean { + return ( + selection.preset !== 'all' || selection.min != null || selection.max != null + ) +} + +export function formatSortChipLabel( + value: string, + options: { value: string; description: string }[] +): string { + const option = options.find(item => item.value === value) + if (option) return formatSortOptionLabel(option) + return '최신순' +} + +export function isSortFilterApplied(value: string): boolean { + return value !== DEFAULT_SORT_VALUE +} + +export function parseSalaryInput(raw: string): number | null { + const digits = raw.replace(/[^\d]/g, '') + if (!digits) return null + const parsed = Number(digits) + return Number.isFinite(parsed) ? parsed : null +} + +export function formatSalaryInput(value: number | null): string { + if (value == null) return '' + return value.toLocaleString('ko-KR') +} + +export type PostingsListFilters = { + province?: string + district?: string + town?: string + minPayAmount?: number + maxPayAmount?: number + payAmountSort?: boolean +} + +export function buildPostingsListFilters(params: { + mode: AlbaFindMode + regionSelection: RegionSelection + sortValue: string + salaryFilter: SalaryFilterSelection +}): PostingsListFilters { + const { mode, regionSelection, sortValue, salaryFilter } = params + const filters: PostingsListFilters = {} + + if (mode === 'region' && isRegionSelectionComplete(regionSelection)) { + if (regionSelection.sido && regionSelection.sido !== '전국(전체)') { + filters.province = regionSelection.sido + } + if (regionSelection.sigungu && regionSelection.sigungu !== '전체') { + filters.district = regionSelection.sigungu + } + if (regionSelection.dong && regionSelection.dong !== '전체') { + filters.town = regionSelection.dong + } + } + + if (sortValue === 'PAY_AMOUNT') { + filters.payAmountSort = true + } + + if (salaryFilter.min != null) { + filters.minPayAmount = salaryFilter.min + } + if (salaryFilter.max != null) { + filters.maxPayAmount = salaryFilter.max + } + + return filters +} diff --git a/src/features/job-lookup-map/lib/regionOptions.ts b/src/features/job-lookup-map/lib/regionOptions.ts new file mode 100644 index 00000000..da3bcfad --- /dev/null +++ b/src/features/job-lookup-map/lib/regionOptions.ts @@ -0,0 +1,47 @@ +import type { PostingFilterOptions } from '@/features/job-lookup-map/types/posting' + +export type RegionStep = 'sido' | 'sigungu' | 'dong' + +export type RegionSelection = { + sido: string | null + sigungu: string | null + dong: string | null +} + +export const EMPTY_REGION_SELECTION: RegionSelection = { + sido: null, + sigungu: null, + dong: null, +} + +export const REGION_STEPS: { key: RegionStep; label: string }[] = [ + { key: 'sido', label: '시/도' }, + { key: 'sigungu', label: '시/군/구' }, + { key: 'dong', label: '읍/면/동' }, +] + +export function formatRegionLabel(selection: RegionSelection): string { + if (!selection.sido || selection.sido === '전국(전체)') return '지역 선택' + if (!selection.sigungu || selection.sigungu === '전체') return selection.sido + if (!selection.dong || selection.dong === '전체') { + return `${selection.sido} ${selection.sigungu}` + } + return `${selection.sigungu} ${selection.dong}` +} + +export function isRegionSelectionComplete(selection: RegionSelection): boolean { + return ( + selection.sido != null && + selection.sigungu != null && + selection.dong != null + ) +} + +export function getRegionOptionsForStep( + step: RegionStep, + filterOptions: Pick +): string[] { + if (step === 'sido') return ['전국(전체)', ...filterOptions.provinces] + if (step === 'sigungu') return ['전체', ...filterOptions.districts] + return ['전체', ...filterOptions.towns] +} diff --git a/src/features/job-lookup-map/types/posting.ts b/src/features/job-lookup-map/types/posting.ts index 3230efba..24299a6c 100644 --- a/src/features/job-lookup-map/types/posting.ts +++ b/src/features/job-lookup-map/types/posting.ts @@ -15,8 +15,16 @@ export interface PostingDetailResponse { schedules: Schedule[] scrapped: boolean } + +export interface PostingFilterOptions { + provinces: string[] + districts: string[] + towns: string[] + sortOptions: PostingSortOption[] +} + export interface Page { - cursor: string + cursor: string | null pageSize: number totalCount: number } @@ -62,3 +70,8 @@ export interface ApplyPostingRequest { postingScheduleId: number description: string } + +export interface PostingSortOption { + value: string + description: string +} diff --git a/src/pages/user/job-lookup-map/index.tsx b/src/pages/user/job-lookup-map/index.tsx index 8e3e02d0..cb553a13 100644 --- a/src/pages/user/job-lookup-map/index.tsx +++ b/src/pages/user/job-lookup-map/index.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, + useMemo, useRef, useState, type KeyboardEvent, @@ -10,10 +11,7 @@ import { generatePath, useNavigate } from 'react-router-dom' import { animate, motion, useMotionValue } from 'framer-motion' import { AlbaFindCategoryBar } from '@/features/job-lookup-map/common/AlbaFindCategoryBar' import { ROUTES } from '@/shared/constants/routes' -import type { - AlbaFindFilterId, - AlbaFindMode, -} from '@/features/job-lookup-map/common/AlbaFindCategoryBar' +import type { AlbaFindMode } from '@/features/job-lookup-map/common/AlbaFindCategoryBar' import { AlbaFindList } from '@/features/job-lookup-map/common/AlbaFindList' import { Albabox } from '@/features/job-lookup-map/common/Albabox' import { usePostings } from '@/features/job-lookup-map/hooks/usePosting' @@ -22,6 +20,17 @@ import { usePostingSearch } from '@/features/job-lookup-map/hooks/usePostingSear import { moveMapToWorkspace } from '@/features/job-lookup-map/lib/moveMapToWorkspace' import { pickSearchTargetPosting } from '@/features/job-lookup-map/lib/pickSearchTargetPosting' import { postingToAlbaboxProps } from '@/features/job-lookup-map/lib/postingToAlbaboxProps' +import { + EMPTY_REGION_SELECTION, + isRegionSelectionComplete, + type RegionSelection, +} from '@/features/job-lookup-map/lib/regionOptions' +import { + DEFAULT_SORT_VALUE, + EMPTY_SALARY_FILTER, + buildPostingsListFilters, + type SalaryFilterSelection, +} from '@/features/job-lookup-map/lib/postingFilters' import { getNaverMaps, type NaverMapInstance, @@ -44,7 +53,12 @@ export function JobLookupMapPage() { const sheetRef = useRef(null) const [maxTranslateY, setMaxTranslateY] = useState(0) const [mode, setMode] = useState('nearby') - const [activeFilter, setActiveFilter] = useState('sort') + const [regionSelection, setRegionSelection] = useState( + EMPTY_REGION_SELECTION + ) + const [sortValue, setSortValue] = useState(DEFAULT_SORT_VALUE) + const [salaryFilter, setSalaryFilter] = + useState(EMPTY_SALARY_FILTER) const [bookmarkById, setBookmarkById] = useState>({}) const [searchQuery, setSearchQuery] = useState('') const [searchList, setSearchList] = useState(null) @@ -52,8 +66,25 @@ export function JobLookupMapPage() { const hasSetInitialSheetYRef = useRef(false) const y = useMotionValue(0) - const { postings, fetchNextPage, hasNextPage, isFetchingNextPage } = - usePostings() + const listFilters = useMemo( + () => + buildPostingsListFilters({ + mode, + regionSelection, + sortValue, + salaryFilter, + }), + [mode, regionSelection, sortValue, salaryFilter] + ) + + const { + postings, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = usePostings(listFilters) const { search } = usePostingSearch() @@ -293,10 +324,39 @@ export function JobLookupMapPage() { { + setRegionSelection(selection) + setSearchList(null) + if (isRegionSelectionComplete(selection)) { + setMode('region') + } + }} + sortValue={sortValue} + onSortChange={value => { + setSortValue(value) + setSearchList(null) + }} + salaryFilter={salaryFilter} + onSalaryChange={selection => { + setSalaryFilter(selection) + setSearchList(null) + }} /> + {isLoading && displayedPostings.length === 0 ? ( +

+ 공고를 불러오는 중… +

+ ) : isError ? ( +

+ 공고를 불러오지 못했습니다. +

+ ) : displayedPostings.length === 0 ? ( +

+ 조건에 맞는 공고가 없습니다. +

+ ) : null} {displayedPostings.map(posting => { const base = postingToAlbaboxProps(posting) const saved = bookmarkById[posting.id] ?? posting.scrapped From e47f8cfb656a6c801f189d9211c35a0e85027b41 Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:35:13 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat=20:=20=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=95=9C=20=EC=95=8C=EB=B0=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.tsx | 5 ++ src/features/job-lookup-map/api/posting.ts | 65 ++++++++++++++++ .../hooks/useScrappedPostings.ts | 49 ++++++++++++ .../lib/postingToAlbaboxProps.ts | 22 +++++- src/features/job-lookup-map/types/posting.ts | 21 +++++ src/pages/my/components/MenuListItem.tsx | 6 +- src/pages/my/index.tsx | 7 ++ src/pages/my/scrapped/index.tsx | 78 +++++++++++++++++++ src/shared/constants/routes.ts | 1 + 9 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 src/features/job-lookup-map/hooks/useScrappedPostings.ts create mode 100644 src/pages/my/scrapped/index.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index 20665f50..1082327f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -40,6 +40,7 @@ import { NicknameEditPage } from '@/pages/my/profile/nickname' import { PasswordEditPage } from '@/pages/my/profile/password' import { SocialAccountPage } from '@/pages/my/profile/social' import { WithdrawPage } from '@/pages/my/withdraw' +import { ScrappedPostingsPage } from '@/pages/my/scrapped' import { ErrorPageRoute } from '@/pages/error' import { MobileLayout } from '@/shared/ui/MobileLayout' import { MobileLayoutWithDocbar } from '@/shared/ui/MobileLayoutWithDocbar' @@ -133,6 +134,10 @@ export function App() { element={} /> } /> + } + /> } diff --git a/src/features/job-lookup-map/api/posting.ts b/src/features/job-lookup-map/api/posting.ts index bea180e7..0a9058c4 100644 --- a/src/features/job-lookup-map/api/posting.ts +++ b/src/features/job-lookup-map/api/posting.ts @@ -2,6 +2,8 @@ import axiosInstance from '@/shared/lib/axiosInstance' import type { CommonApiResponse } from '@/shared/types/common' import type { ApplyPostingRequest, + FavoritePostingItem, + FavoritePostingListResponse, PostingDetailResponse, PostingFilterOptions, PostingListResponse, @@ -162,6 +164,69 @@ export async function applyPosting( ) } +function normalizeFavoritePostingListResponse( + value: unknown +): FavoritePostingListResponse { + const payload = isCommonApiEnvelope(value) ? value.data : value + if (payload === null || typeof payload !== 'object') { + throw new Error('스크랩 목록을 불러오지 못했습니다.') + } + + const record = payload as Record + const data = Array.isArray(record.data) + ? (record.data as FavoritePostingItem[]) + : [] + const pageRaw = record.page + + if (pageRaw !== null && typeof pageRaw === 'object') { + const page = pageRaw as Record + const cursor = page.cursor + return { + data, + page: { + cursor: + typeof cursor === 'string' + ? cursor + : cursor == null + ? null + : String(cursor), + pageSize: + typeof page.pageSize === 'number' ? page.pageSize : data.length, + totalCount: + typeof page.totalCount === 'number' ? page.totalCount : data.length, + }, + } + } + + return { + data, + page: { + cursor: null, + pageSize: data.length, + totalCount: data.length, + }, + } +} + +/** GET /app/users/me/postings/favorites — 사용자 공고 스크랩 목록 조회 */ +export async function fetchFavoritePostings(params: { + pageSize: number + cursor?: string +}): Promise { + const { pageSize, cursor } = params + + const response = await axiosInstance.get( + '/app/users/me/postings/favorites', + { + params: { + pageSize, + ...(cursor !== undefined && cursor !== '' && { cursor }), + }, + } + ) + return normalizeFavoritePostingListResponse(response.data) +} + /** GET /app/postings/filter-options — 공고 목록 필터 옵션 조회 */ export async function fetchPostingFilterOptions(): Promise { const response = await axiosInstance.get< diff --git a/src/features/job-lookup-map/hooks/useScrappedPostings.ts b/src/features/job-lookup-map/hooks/useScrappedPostings.ts new file mode 100644 index 00000000..fa480bfd --- /dev/null +++ b/src/features/job-lookup-map/hooks/useScrappedPostings.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' +import { fetchFavoritePostings } from '@/features/job-lookup-map/api/posting' +import type { FavoritePostingItem } from '@/features/job-lookup-map/types/posting' + +const PAGE_SIZE = 10 + +export function useScrappedPostings() { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isFetching, + isError, + refetch, + } = useInfiniteQuery({ + queryKey: ['jobLookupMap', 'favoritePostings', PAGE_SIZE] as const, + queryFn: ({ pageParam }) => + fetchFavoritePostings({ + pageSize: PAGE_SIZE, + cursor: pageParam as string | undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.page.cursor ?? undefined, + }) + + const favorites = useMemo( + () => + data?.pages.flatMap((page): FavoritePostingItem[] => page.data ?? []) ?? + [], + [data] + ) + + const totalCount = data?.pages[0]?.page.totalCount ?? 0 + + return { + favorites, + totalCount, + fetchNextPage, + hasNextPage: Boolean(hasNextPage), + isFetchingNextPage, + isLoading: isPending, + isFetching, + isError, + refetch, + } +} diff --git a/src/features/job-lookup-map/lib/postingToAlbaboxProps.ts b/src/features/job-lookup-map/lib/postingToAlbaboxProps.ts index 5b22a0de..78f66634 100644 --- a/src/features/job-lookup-map/lib/postingToAlbaboxProps.ts +++ b/src/features/job-lookup-map/lib/postingToAlbaboxProps.ts @@ -2,7 +2,10 @@ import { formatDistanceToNowStrict } from 'date-fns' import { ko } from 'date-fns/locale' import type { AlbaboxProps } from '@/features/job-lookup-map/common/Albabox' -import type { Posting } from '@/features/job-lookup-map/types/posting' +import type { + FavoritePostingItem, + Posting, +} from '@/features/job-lookup-map/types/posting' export function formatPostedAgo(createdAt: string): string { const d = new Date(createdAt) @@ -91,3 +94,20 @@ export function postingToAlbaboxProps( saved: p.scrapped, } } + +/** `GET /app/users/me/postings/favorites` 항목 → Albabox props */ +export function favoritePostingToAlbaboxProps( + item: FavoritePostingItem +): Omit { + const { posting, createdAt } = item + return { + storeName: posting.businessName, + title: posting.title, + wageAmount: posting.payAmount.toLocaleString('ko-KR'), + timeRange: '-', + workDays: '-', + town: '-', + postedAgo: formatPostedAgo(createdAt), + saved: true, + } +} diff --git a/src/features/job-lookup-map/types/posting.ts b/src/features/job-lookup-map/types/posting.ts index 24299a6c..7f7e90e4 100644 --- a/src/features/job-lookup-map/types/posting.ts +++ b/src/features/job-lookup-map/types/posting.ts @@ -75,3 +75,24 @@ export interface PostingSortOption { value: string description: string } + +/** `GET /app/users/me/postings/favorites` 응답의 공고 요약 */ +export interface FavoritePostingSummary { + id: number + businessName: string + title: string + payAmount: number + paymentType: string +} + +/** 스크랩(즐겨찾기) 한 건 */ +export interface FavoritePostingItem { + id: number + posting: FavoritePostingSummary + createdAt: string +} + +export interface FavoritePostingListResponse { + page: Page + data: FavoritePostingItem[] +} diff --git a/src/pages/my/components/MenuListItem.tsx b/src/pages/my/components/MenuListItem.tsx index 9d47e422..e0323e2d 100644 --- a/src/pages/my/components/MenuListItem.tsx +++ b/src/pages/my/components/MenuListItem.tsx @@ -6,6 +6,7 @@ interface MenuListItemProps { label: string onClick?: () => void isLast?: boolean + iconClassName?: string } export function MenuListItem({ @@ -13,6 +14,7 @@ export function MenuListItem({ label, onClick, isLast = false, + iconClassName, }: MenuListItemProps) { return ( +
+ +

{title}

+ +

+ 시급 {wageAmount}원 +

+ +

+ {savedAgoLabel} +

+ + ) +} diff --git a/src/pages/my/scrapped/index.tsx b/src/pages/my/scrapped/index.tsx index c01809ef..4b52b2a3 100644 --- a/src/pages/my/scrapped/index.tsx +++ b/src/pages/my/scrapped/index.tsx @@ -1,16 +1,21 @@ +import { useState } from 'react' import { generatePath, useNavigate } from 'react-router-dom' -import { AlbaFindList } from '@/features/job-lookup-map/common/AlbaFindList' -import { Albabox } from '@/features/job-lookup-map/common/Albabox' +import { useRemoveFavoritePosting } from '@/features/job-lookup-map/hooks/useRemoveFavoritePosting' import { useScrappedPostings } from '@/features/job-lookup-map/hooks/useScrappedPostings' -import { favoritePostingToAlbaboxProps } from '@/features/job-lookup-map/lib/postingToAlbaboxProps' +import { formatPostedAgo } from '@/features/job-lookup-map/lib/postingToAlbaboxProps' +import { ScrappedPostingCard } from '@/pages/my/scrapped/components/ScrappedPostingCard' import { ROUTES } from '@/shared/constants/routes' import { shouldShowInfiniteListLoadMore } from '@/shared/lib/listLoadMoreVisibility' +import { ConfirmModal } from '@/shared/ui/common/ConfirmModal' import { Navbar } from '@/shared/ui/common/Navbar' import { MoreButton } from '@/shared/ui/common/MoreButton' import { Spinner } from '@/shared/ui/Spinner' export function ScrappedPostingsPage() { const navigate = useNavigate() + const { mutate: removeFavorite, isPending: isRemoving } = + useRemoveFavoritePosting() + const [pendingPostingId, setPendingPostingId] = useState(null) const { favorites, @@ -22,13 +27,20 @@ export function ScrappedPostingsPage() { isError, } = useScrappedPostings() + const handleConfirmRemove = () => { + if (pendingPostingId == null) return + removeFavorite(pendingPostingId, { + onSettled: () => setPendingPostingId(null), + }) + } + return (
-
+
{isLoading ? (
@@ -42,35 +54,48 @@ export function ScrappedPostingsPage() { 스크랩한 알바가 없습니다.

) : ( - - {favorites.map(item => { - const base = favoritePostingToAlbaboxProps(item) - return ( - - navigate( - generatePath(ROUTES.USER.JOB_LOOKUP_MAP_DETAIL, { - postingId: String(item.posting.id), - }) - ) - } - /> - ) - })} +
+ {favorites.map(item => ( + setPendingPostingId(item.posting.id)} + onClick={() => + navigate( + generatePath(ROUTES.USER.JOB_LOOKUP_MAP_DETAIL, { + postingId: String(item.posting.id), + }) + ) + } + /> + ))} {shouldShowInfiniteListLoadMore(hasNextPage, totalCount) && ( void fetchNextPage()} disabled={isFetchingNextPage} /> )} - +
)}
+ + { + if (!isRemoving) setPendingPostingId(null) + }} + />
) } diff --git a/src/pages/user/job-lookup-map-detail/index.tsx b/src/pages/user/job-lookup-map-detail/index.tsx index c7897fdc..717472b5 100644 --- a/src/pages/user/job-lookup-map-detail/index.tsx +++ b/src/pages/user/job-lookup-map-detail/index.tsx @@ -1,8 +1,10 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { generatePath, useNavigate, useParams } from 'react-router-dom' import { ROUTES } from '@/shared/constants/routes' import ChevronLeftIcon from '@/assets/icons/nav/chevron-left.svg?react' +import BookmarkIcon from '@/assets/icons/job-lookup-map/Bookmark.svg?react' import { usePostingDetail } from '@/features/job-lookup-map/hooks/usePostingDetail' +import { useToggleFavoritePosting } from '@/features/job-lookup-map/hooks/useToggleFavoritePosting' import { formatPostedAgo, formatWorkDaysForDisplay, @@ -37,6 +39,11 @@ export function JobLookupMapDetailPage() { const { data, isPending, isError } = usePostingDetail( idOk ? postingId : undefined ) + const { toggleFavorite, isPending: isFavoritePending } = + useToggleFavoritePosting() + const [savedById, setSavedById] = useState>({}) + const saved = + (idOk ? savedById[postingId] : undefined) ?? data?.scrapped ?? false const schedule = data?.schedules?.[0] const workDaysLine = useMemo(() => { @@ -57,6 +64,18 @@ export function JobLookupMapDetailPage() { ? formatDurationHint(schedule.startTime, schedule.endTime) : null + const handleBookmarkClick = () => { + if (!idOk || isFavoritePending) return + toggleFavorite({ + postingId, + saved, + onOptimistic: nextSaved => + setSavedById(prev => ({ ...prev, [postingId]: nextSaved })), + onError: rollbackSaved => + setSavedById(prev => ({ ...prev, [postingId]: rollbackSaved })), + }) + } + return (
@@ -71,7 +90,23 @@ export function JobLookupMapDetailPage() {

알바 상세

-
+
{!idOk && ( diff --git a/src/pages/user/job-lookup-map/index.tsx b/src/pages/user/job-lookup-map/index.tsx index ed416123..9893d200 100644 --- a/src/pages/user/job-lookup-map/index.tsx +++ b/src/pages/user/job-lookup-map/index.tsx @@ -15,8 +15,7 @@ import type { AlbaFindMode } from '@/features/job-lookup-map/common/AlbaFindCate import { AlbaFindList } from '@/features/job-lookup-map/common/AlbaFindList' import { Albabox } from '@/features/job-lookup-map/common/Albabox' import { usePostings } from '@/features/job-lookup-map/hooks/usePosting' -import { useAddFavoritePosting } from '@/features/job-lookup-map/hooks/useAddFavoritePosting' -import { useRemoveFavoritePosting } from '@/features/job-lookup-map/hooks/useRemoveFavoritePosting' +import { useToggleFavoritePosting } from '@/features/job-lookup-map/hooks/useToggleFavoritePosting' import { usePostingMapMarkers } from '@/features/job-lookup-map/hooks/usePostingMapMarkers' import { usePostingSearch } from '@/features/job-lookup-map/hooks/usePostingSearch' import { moveMapToWorkspace } from '@/features/job-lookup-map/lib/moveMapToWorkspace' @@ -88,8 +87,7 @@ export function JobLookupMapPage() { isError, } = usePostings(listFilters) - const { mutate: addFavorite } = useAddFavoritePosting() - const { mutate: removeFavorite } = useRemoveFavoritePosting() + const { toggleFavorite } = useToggleFavoritePosting() const { search } = usePostingSearch() @@ -371,32 +369,19 @@ export function JobLookupMapPage() { {...base} saved={saved} onBookmarkClick={() => { - if (saved) { - setBookmarkById(prev => ({ - ...prev, - [posting.id]: false, - })) - removeFavorite(posting.id, { - onError: () => { - setBookmarkById(prev => ({ - ...prev, - [posting.id]: true, - })) - }, - }) - return - } - setBookmarkById(prev => ({ - ...prev, - [posting.id]: true, - })) - addFavorite(posting.id, { - onError: () => { + toggleFavorite({ + postingId: posting.id, + saved, + onOptimistic: nextSaved => + setBookmarkById(prev => ({ + ...prev, + [posting.id]: nextSaved, + })), + onError: rollbackSaved => setBookmarkById(prev => ({ ...prev, - [posting.id]: false, - })) - }, + [posting.id]: rollbackSaved, + })), }) }} onClick={() => { From b92b02725f66cbcd0a02d8338b27b18265c4d214 Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:36:12 +0900 Subject: [PATCH 07/15] =?UTF-8?q?fix=20:=20=EC=BD=94=EB=93=9C=EB=A0=88?= =?UTF-8?q?=EB=B9=97=20=EC=88=98=EC=A0=95=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/job-lookup-map/api/posting.ts | 141 +++++++++++------- .../common/SalarySelectDrawer.tsx | 5 + src/pages/my/index.tsx | 11 +- 3 files changed, 97 insertions(+), 60 deletions(-) diff --git a/src/features/job-lookup-map/api/posting.ts b/src/features/job-lookup-map/api/posting.ts index c667f74f..b8d1b98b 100644 --- a/src/features/job-lookup-map/api/posting.ts +++ b/src/features/job-lookup-map/api/posting.ts @@ -28,6 +28,75 @@ function isCommonApiEnvelope( ) } +function normalizePageCursor(cursor: unknown): string | null { + if (typeof cursor === 'string') { + return cursor !== '' ? cursor : null + } + if (cursor == null) return null + const asString = String(cursor) + return asString !== '' ? asString : null +} + +function isPostingListItem( + value: unknown +): value is PostingListResponse['data'][number] { + if (value === null || typeof value !== 'object') return false + const record = value as Record + const workspace = record.workspace + return ( + typeof record.id === 'number' && + typeof record.title === 'string' && + typeof record.payAmount === 'number' && + typeof record.paymentType === 'string' && + typeof record.createdAt === 'string' && + typeof record.scrapped === 'boolean' && + Array.isArray(record.keywords) && + Array.isArray(record.schedules) && + workspace !== null && + typeof workspace === 'object' && + typeof (workspace as { id?: unknown }).id === 'number' && + typeof (workspace as { businessName?: unknown }).businessName === 'string' + ) +} + +function isFavoritePostingItem(value: unknown): value is FavoritePostingItem { + if (value === null || typeof value !== 'object') return false + const record = value as Record + const posting = record.posting + if (posting === null || typeof posting !== 'object') return false + const postingRecord = posting as Record + return ( + typeof record.id === 'number' && + typeof record.createdAt === 'string' && + typeof postingRecord.id === 'number' && + typeof postingRecord.businessName === 'string' && + typeof postingRecord.title === 'string' && + typeof postingRecord.payAmount === 'number' && + typeof postingRecord.paymentType === 'string' + ) +} + +function normalizePage( + pageRaw: unknown, + fallbackCount: number +): PostingListResponse['page'] { + if (pageRaw === null || typeof pageRaw !== 'object') { + return { + cursor: null, + pageSize: fallbackCount, + totalCount: fallbackCount, + } + } + + const page = pageRaw as Record + return { + cursor: normalizePageCursor(page.cursor), + pageSize: typeof page.pageSize === 'number' ? page.pageSize : fallbackCount, + totalCount: + typeof page.totalCount === 'number' ? page.totalCount : fallbackCount, + } +} + function normalizePostingListResponse(value: unknown): PostingListResponse { const payload = isCommonApiEnvelope(value) ? value.data : value if (payload === null || typeof payload !== 'object') { @@ -35,38 +104,16 @@ function normalizePostingListResponse(value: unknown): PostingListResponse { } const record = payload as Record - const data = Array.isArray(record.data) - ? (record.data as PostingListResponse['data']) - : [] - const pageRaw = record.page - - if (pageRaw !== null && typeof pageRaw === 'object') { - const page = pageRaw as Record - const cursor = page.cursor - return { - data, - page: { - cursor: - typeof cursor === 'string' - ? cursor - : cursor == null - ? null - : String(cursor), - pageSize: - typeof page.pageSize === 'number' ? page.pageSize : data.length, - totalCount: - typeof page.totalCount === 'number' ? page.totalCount : data.length, - }, - } + if (!Array.isArray(record.data)) { + throw new Error('공고 목록 응답 형식이 올바르지 않습니다.') + } + if (!record.data.every(isPostingListItem)) { + throw new Error('공고 목록 응답 형식이 올바르지 않습니다.') } return { - data, - page: { - cursor: null, - pageSize: data.length, - totalCount: data.length, - }, + data: record.data, + page: normalizePage(record.page, record.data.length), } } @@ -173,38 +220,16 @@ function normalizeFavoritePostingListResponse( } const record = payload as Record - const data = Array.isArray(record.data) - ? (record.data as FavoritePostingItem[]) - : [] - const pageRaw = record.page - - if (pageRaw !== null && typeof pageRaw === 'object') { - const page = pageRaw as Record - const cursor = page.cursor - return { - data, - page: { - cursor: - typeof cursor === 'string' - ? cursor - : cursor == null - ? null - : String(cursor), - pageSize: - typeof page.pageSize === 'number' ? page.pageSize : data.length, - totalCount: - typeof page.totalCount === 'number' ? page.totalCount : data.length, - }, - } + if (!Array.isArray(record.data)) { + throw new Error('스크랩 목록 응답 형식이 올바르지 않습니다.') + } + if (!record.data.every(isFavoritePostingItem)) { + throw new Error('스크랩 목록 응답 형식이 올바르지 않습니다.') } return { - data, - page: { - cursor: null, - pageSize: data.length, - totalCount: data.length, - }, + data: record.data, + page: normalizePage(record.page, record.data.length), } } diff --git a/src/features/job-lookup-map/common/SalarySelectDrawer.tsx b/src/features/job-lookup-map/common/SalarySelectDrawer.tsx index a7e57ed0..f4aeca71 100644 --- a/src/features/job-lookup-map/common/SalarySelectDrawer.tsx +++ b/src/features/job-lookup-map/common/SalarySelectDrawer.tsx @@ -66,6 +66,11 @@ function SalarySelectDrawerBody({ } const handleApply = () => { + if (draft.min != null && draft.max != null && draft.min > draft.max) { + onApply({ ...draft, min: draft.max, max: draft.min }) + onOpenChange(false) + return + } onApply(draft) onOpenChange(false) } diff --git a/src/pages/my/index.tsx b/src/pages/my/index.tsx index 02e0877b..9a40ab11 100644 --- a/src/pages/my/index.tsx +++ b/src/pages/my/index.tsx @@ -21,6 +21,7 @@ interface MenuItem { icon: React.ComponentType> path?: string onlyManager?: boolean + onlyWorker?: boolean } const MENU_ITEMS: MenuItem[] = [ @@ -30,6 +31,7 @@ const MENU_ITEMS: MenuItem[] = [ label: '스크랩한 알바', icon: BookmarkIcon, path: ROUTES.MY.SCRAPPED_POSTINGS, + onlyWorker: true, }, { key: 'store-apply', @@ -76,6 +78,11 @@ export function MyPage() { const isManager = scope === 'MANAGER' const nickname = user.nickname || user.name || '알터' const realName = user.name + const visibleMenuItems = MENU_ITEMS.filter(item => { + if (item.onlyManager && !isManager) return false + if (item.onlyWorker && isManager) return false + return true + }) const handleEditProfile = () => { navigate(ROUTES.MY.PROFILE) @@ -132,12 +139,12 @@ export function MyPage() { aria-label="마이페이지 메뉴" className="mt-3 flex flex-col rounded-2xl" > - {MENU_ITEMS.map((item, index) => ( + {visibleMenuItems.map((item, index) => ( item.path && navigate(item.path)} /> ))} From ec7a8125207580c91ddc55f498e4200f79e1b1cb Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:04:04 +0900 Subject: [PATCH 08/15] =?UTF-8?q?fix=20:=20=EC=95=8C=EB=B0=94=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EB=AA=A9=EB=A1=9D=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/job-lookup-map/api/posting.ts | 135 +++++++++++++++------ src/pages/user/job-lookup-map/index.tsx | 4 +- 2 files changed, 101 insertions(+), 38 deletions(-) diff --git a/src/features/job-lookup-map/api/posting.ts b/src/features/job-lookup-map/api/posting.ts index b8d1b98b..393cd3b7 100644 --- a/src/features/job-lookup-map/api/posting.ts +++ b/src/features/job-lookup-map/api/posting.ts @@ -37,43 +37,95 @@ function normalizePageCursor(cursor: unknown): string | null { return asString !== '' ? asString : null } -function isPostingListItem( +function parsePostingListItem( value: unknown -): value is PostingListResponse['data'][number] { - if (value === null || typeof value !== 'object') return false +): PostingListResponse['data'][number] | null { + if (value === null || typeof value !== 'object') return null const record = value as Record - const workspace = record.workspace - return ( - typeof record.id === 'number' && - typeof record.title === 'string' && - typeof record.payAmount === 'number' && - typeof record.paymentType === 'string' && - typeof record.createdAt === 'string' && - typeof record.scrapped === 'boolean' && - Array.isArray(record.keywords) && - Array.isArray(record.schedules) && - workspace !== null && - typeof workspace === 'object' && - typeof (workspace as { id?: unknown }).id === 'number' && - typeof (workspace as { businessName?: unknown }).businessName === 'string' - ) + const workspaceRaw = record.workspace + + // 목록 렌더에 필수인 필드만 엄격 검사. 나머지는 기본값으로 보정. + if ( + typeof record.id !== 'number' || + typeof record.title !== 'string' || + typeof record.payAmount !== 'number' || + workspaceRaw === null || + typeof workspaceRaw !== 'object' + ) { + return null + } + + const workspace = workspaceRaw as Record + if (typeof workspace.id !== 'number') return null + + const businessName = + typeof workspace.businessName === 'string' + ? workspace.businessName + : typeof workspace.name === 'string' + ? workspace.name + : '' + + return { + id: record.id, + title: record.title, + payAmount: record.payAmount, + paymentType: + typeof record.paymentType === 'string' ? record.paymentType : 'HOURLY', + createdAt: typeof record.createdAt === 'string' ? record.createdAt : '', + keywords: Array.isArray(record.keywords) + ? (record.keywords as PostingListResponse['data'][number]['keywords']) + : [], + schedules: Array.isArray(record.schedules) + ? (record.schedules as PostingListResponse['data'][number]['schedules']) + : [], + workspace: { + id: workspace.id, + businessName, + name: typeof workspace.name === 'string' ? workspace.name : businessName, + latitude: typeof workspace.latitude === 'number' ? workspace.latitude : 0, + longitude: + typeof workspace.longitude === 'number' ? workspace.longitude : 0, + fullAddress: + typeof workspace.fullAddress === 'string' ? workspace.fullAddress : '', + town: typeof workspace.town === 'string' ? workspace.town : '', + }, + scrapped: typeof record.scrapped === 'boolean' ? record.scrapped : false, + } } -function isFavoritePostingItem(value: unknown): value is FavoritePostingItem { - if (value === null || typeof value !== 'object') return false +function parseFavoritePostingItem(value: unknown): FavoritePostingItem | null { + if (value === null || typeof value !== 'object') return null const record = value as Record const posting = record.posting - if (posting === null || typeof posting !== 'object') return false + if (posting === null || typeof posting !== 'object') return null const postingRecord = posting as Record - return ( - typeof record.id === 'number' && - typeof record.createdAt === 'string' && - typeof postingRecord.id === 'number' && - typeof postingRecord.businessName === 'string' && - typeof postingRecord.title === 'string' && - typeof postingRecord.payAmount === 'number' && - typeof postingRecord.paymentType === 'string' - ) + + if ( + typeof record.id !== 'number' || + typeof postingRecord.id !== 'number' || + typeof postingRecord.title !== 'string' || + typeof postingRecord.payAmount !== 'number' + ) { + return null + } + + return { + id: record.id, + createdAt: typeof record.createdAt === 'string' ? record.createdAt : '', + posting: { + id: postingRecord.id, + businessName: + typeof postingRecord.businessName === 'string' + ? postingRecord.businessName + : '', + title: postingRecord.title, + payAmount: postingRecord.payAmount, + paymentType: + typeof postingRecord.paymentType === 'string' + ? postingRecord.paymentType + : 'HOURLY', + }, + } } function normalizePage( @@ -107,13 +159,19 @@ function normalizePostingListResponse(value: unknown): PostingListResponse { if (!Array.isArray(record.data)) { throw new Error('공고 목록 응답 형식이 올바르지 않습니다.') } - if (!record.data.every(isPostingListItem)) { + + const data = record.data + .map(parsePostingListItem) + .filter((item): item is PostingListResponse['data'][number] => item != null) + + // 항목이 있는데 전부 파싱 실패하면 스키마 문제로 보고 에러 처리 + if (record.data.length > 0 && data.length === 0) { throw new Error('공고 목록 응답 형식이 올바르지 않습니다.') } return { - data: record.data, - page: normalizePage(record.page, record.data.length), + data, + page: normalizePage(record.page, data.length), } } @@ -223,13 +281,18 @@ function normalizeFavoritePostingListResponse( if (!Array.isArray(record.data)) { throw new Error('스크랩 목록 응답 형식이 올바르지 않습니다.') } - if (!record.data.every(isFavoritePostingItem)) { + + const data = record.data + .map(parseFavoritePostingItem) + .filter((item): item is FavoritePostingItem => item != null) + + if (record.data.length > 0 && data.length === 0) { throw new Error('스크랩 목록 응답 형식이 올바르지 않습니다.') } return { - data: record.data, - page: normalizePage(record.page, record.data.length), + data, + page: normalizePage(record.page, data.length), } } diff --git a/src/pages/user/job-lookup-map/index.tsx b/src/pages/user/job-lookup-map/index.tsx index 9893d200..e5d4a09c 100644 --- a/src/pages/user/job-lookup-map/index.tsx +++ b/src/pages/user/job-lookup-map/index.tsx @@ -321,9 +321,9 @@ export function JobLookupMapPage() { }} className="absolute inset-x-0 bottom-[30px] z-[40] mx-auto flex h-[calc(100dvh-78px)] max-h-[calc(100dvh-78px)] w-full max-w-[428px] flex-col overflow-hidden rounded-t-[32px] border border-line-2 border-b-0 bg-white" > -
+ -
+
Date: Wed, 29 Jul 2026 19:19:22 +0900 Subject: [PATCH 09/15] =?UTF-8?q?fix=20:=20=EC=A7=80=EC=97=AD=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=ED=95=84=ED=84=B0=EB=A7=81=20api=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 17 ++-- src/features/job-lookup-map/api/posting.ts | 17 ++-- .../common/AlbaFindCategoryBar.tsx | 5 +- .../common/RegionSelectDrawer.tsx | 92 ++++++++++++++----- .../common/SortSelectDrawer.tsx | 64 +++++-------- .../job-lookup-map/hooks/useAddresses.ts | 19 ++++ .../hooks/usePostingFilterOptions.ts | 23 ----- .../job-lookup-map/lib/regionOptions.ts | 32 +++++-- src/features/job-lookup-map/types/posting.ts | 22 +++-- 9 files changed, 171 insertions(+), 120 deletions(-) create mode 100644 src/features/job-lookup-map/hooks/useAddresses.ts delete mode 100644 src/features/job-lookup-map/hooks/usePostingFilterOptions.ts diff --git a/package-lock.json b/package-lock.json index 69f22928..c740b9db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4242,13 +4242,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.12", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.12.tgz", - "integrity": "sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw==", + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz", + "integrity": "sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -4385,9 +4388,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001762", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", - "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { diff --git a/src/features/job-lookup-map/api/posting.ts b/src/features/job-lookup-map/api/posting.ts index 393cd3b7..dee9b4b3 100644 --- a/src/features/job-lookup-map/api/posting.ts +++ b/src/features/job-lookup-map/api/posting.ts @@ -5,7 +5,8 @@ import type { FavoritePostingItem, FavoritePostingListResponse, PostingDetailResponse, - PostingFilterOptions, + AddressesResponse, + AddressItem, PostingListResponse, } from '@/features/job-lookup-map/types/posting' @@ -329,10 +330,14 @@ export async function removeFavoritePosting(postingId: number): Promise { ) } -/** GET /app/postings/filter-options — 공고 목록 필터 옵션 조회 */ -export async function fetchPostingFilterOptions(): Promise { +/** GET /app/addresses — 단계별 행정구역 주소 조회 */ +export async function fetchAddresses(code?: string): Promise { const response = await axiosInstance.get< - CommonApiResponse - >('/app/postings/filter-options') - return response.data.data + CommonApiResponse + >('/app/addresses', { + params: code ? { code } : undefined, + }) + + const addresses = response.data.data?.addresses + return Array.isArray(addresses) ? addresses : [] } diff --git a/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx b/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx index fe088ca0..3a6508b9 100644 --- a/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx +++ b/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx @@ -3,7 +3,6 @@ import ChevrondownIcon from '@/assets/icons/job-lookup-map/Chevrondown.svg?react import { RegionSelectDrawer } from '@/features/job-lookup-map/common/RegionSelectDrawer' import { SalarySelectDrawer } from '@/features/job-lookup-map/common/SalarySelectDrawer' import { SortSelectDrawer } from '@/features/job-lookup-map/common/SortSelectDrawer' -import { usePostingFilterOptions } from '@/features/job-lookup-map/hooks/usePostingFilterOptions' import { DEFAULT_SORT_VALUE, EMPTY_SALARY_FILTER, @@ -75,9 +74,7 @@ export function AlbaFindCategoryBar({ salaryFilter = EMPTY_SALARY_FILTER, onSalaryChange, }: AlbaFindCategoryBarProps) { - const { sortOptions } = usePostingFilterOptions() - const resolvedSortOptions = - sortOptions.length > 0 ? sortOptions : FALLBACK_SORT_OPTIONS + const resolvedSortOptions = FALLBACK_SORT_OPTIONS const [isRegionDrawerOpen, setIsRegionDrawerOpen] = useState(false) const [isSortDrawerOpen, setIsSortDrawerOpen] = useState(false) diff --git a/src/features/job-lookup-map/common/RegionSelectDrawer.tsx b/src/features/job-lookup-map/common/RegionSelectDrawer.tsx index 2b72cf56..bbd980c9 100644 --- a/src/features/job-lookup-map/common/RegionSelectDrawer.tsx +++ b/src/features/job-lookup-map/common/RegionSelectDrawer.tsx @@ -2,12 +2,13 @@ import { useMemo, useState } from 'react' import { Drawer } from 'vaul' import { ChevronRightIcon } from '@/assets/icons/ChevronRightIcon' -import { usePostingFilterOptions } from '@/features/job-lookup-map/hooks/usePostingFilterOptions' +import { useAddresses } from '@/features/job-lookup-map/hooks/useAddresses' import { EMPTY_REGION_SELECTION, REGION_STEPS, getRegionOptionsForStep, isRegionSelectionComplete, + type RegionOption, type RegionSelection, type RegionStep, } from '@/features/job-lookup-map/lib/regionOptions' @@ -69,48 +70,89 @@ function RegionSelectDrawerBody({ onOpenChange: (open: boolean) => void onApply: (selection: RegionSelection) => void }) { - const { provinces, districts, towns, isLoading, isError } = - usePostingFilterOptions() - const [draft, setDraft] = useState(value) const [step, setStep] = useState('sido') + const parentCode = + step === 'sigungu' + ? (draft.sidoCode ?? undefined) + : step === 'dong' + ? (draft.sigunguCode ?? undefined) + : undefined + + const addressesEnabled = + step === 'sido' || + (step === 'sigungu' && draft.sidoCode != null) || + (step === 'dong' && draft.sigunguCode != null) + + const { addresses, isLoading, isError } = useAddresses( + parentCode, + addressesEnabled + ) + const options = useMemo( - () => - getRegionOptionsForStep(step, { - provinces, - districts, - towns, - }), - [step, provinces, districts, towns] + () => getRegionOptionsForStep(step, addresses), + [step, addresses] ) - const handleSelect = (option: string) => { + const handleSelect = (option: RegionOption) => { if (step === 'sido') { + if (option.name === '전국(전체)') { + setDraft(EMPTY_REGION_SELECTION) + return + } + const next: RegionSelection = { - sido: option, + sido: option.name, + sidoCode: option.code, sigungu: null, + sigunguCode: null, dong: null, + dongCode: null, } setDraft(next) - if (option === '전국(전체)') return setStep('sigungu') return } if (step === 'sigungu') { + if (option.name === '전체') { + setDraft({ + ...draft, + sigungu: '전체', + sigunguCode: null, + dong: '전체', + dongCode: null, + }) + return + } + const next: RegionSelection = { ...draft, - sigungu: option, + sigungu: option.name, + sigunguCode: option.code, dong: null, + dongCode: null, } setDraft(next) - if (option === '전체') return setStep('dong') return } - setDraft({ ...draft, dong: option }) + if (option.name === '전체') { + setDraft({ + ...draft, + dong: '전체', + dongCode: null, + }) + return + } + + setDraft({ + ...draft, + dong: option.name, + dongCode: option.code, + }) } const handleReset = () => { @@ -146,7 +188,7 @@ function RegionSelectDrawerBody({ const canApply = isRegionSelectionComplete(draft) return ( - <> +
@@ -195,7 +237,7 @@ function RegionSelectDrawerBody({ })}
-
+
{isLoading ? (

지역 정보를 불러오는 중… @@ -211,9 +253,9 @@ function RegionSelectDrawerBody({ ) : (

    {options.map(option => { - const selected = selectedForStep === option + const selected = selectedForStep === option.name return ( -
  • +
-
+
- +
) } @@ -277,7 +319,7 @@ export function RegionSelectDrawer({ -
+
{open ? ( void onApply: (value: string) => void }) { - const { sortOptions, isLoading, isError } = usePostingFilterOptions() - const options = - sortOptions.length > 0 ? sortOptions : [...FALLBACK_SORT_OPTIONS] const [draft, setDraft] = useState(value) const handleApply = () => { @@ -45,41 +41,31 @@ function SortSelectDrawerBody({ return ( <>
- {isLoading ? ( -

- 정렬 옵션을 불러오는 중… -

- ) : isError ? ( -

- 정렬 옵션을 불러오지 못했습니다. -

- ) : ( -
    - {options.map(option => { - const selected = draft === option.value - return ( -
  • - -
  • - ) - })} -
- )} + {formatSortOptionLabel(option)} + + + + + ) + })} +
diff --git a/src/features/job-lookup-map/hooks/useAddresses.ts b/src/features/job-lookup-map/hooks/useAddresses.ts new file mode 100644 index 00000000..0b85355e --- /dev/null +++ b/src/features/job-lookup-map/hooks/useAddresses.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query' + +import { fetchAddresses } from '@/features/job-lookup-map/api/posting' + +export function useAddresses(code?: string, enabled = true) { + const { data, isPending, isError, isFetching, refetch } = useQuery({ + queryKey: ['jobLookupMap', 'addresses', code ?? 'root'] as const, + queryFn: () => fetchAddresses(code), + enabled, + }) + + return { + addresses: data ?? [], + isLoading: isPending, + isFetching, + isError, + refetch, + } +} diff --git a/src/features/job-lookup-map/hooks/usePostingFilterOptions.ts b/src/features/job-lookup-map/hooks/usePostingFilterOptions.ts deleted file mode 100644 index 44d4d5ec..00000000 --- a/src/features/job-lookup-map/hooks/usePostingFilterOptions.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useQuery } from '@tanstack/react-query' - -import { fetchPostingFilterOptions } from '@/features/job-lookup-map/api/posting' - -export function usePostingFilterOptions(enabled = true) { - const { data, isPending, isError, isFetching, refetch } = useQuery({ - queryKey: ['jobLookupMap', 'filterOptions'] as const, - queryFn: fetchPostingFilterOptions, - enabled, - }) - - return { - filterOptions: data, - provinces: data?.provinces ?? [], - districts: data?.districts ?? [], - towns: data?.towns ?? [], - sortOptions: data?.sortOptions ?? [], - isLoading: isPending, - isFetching, - isError, - refetch, - } -} diff --git a/src/features/job-lookup-map/lib/regionOptions.ts b/src/features/job-lookup-map/lib/regionOptions.ts index da3bcfad..4c2e70b9 100644 --- a/src/features/job-lookup-map/lib/regionOptions.ts +++ b/src/features/job-lookup-map/lib/regionOptions.ts @@ -1,4 +1,4 @@ -import type { PostingFilterOptions } from '@/features/job-lookup-map/types/posting' +import type { AddressItem } from '@/features/job-lookup-map/types/posting' export type RegionStep = 'sido' | 'sigungu' | 'dong' @@ -6,12 +6,18 @@ export type RegionSelection = { sido: string | null sigungu: string | null dong: string | null + sidoCode: string | null + sigunguCode: string | null + dongCode: string | null } export const EMPTY_REGION_SELECTION: RegionSelection = { sido: null, sigungu: null, dong: null, + sidoCode: null, + sigunguCode: null, + dongCode: null, } export const REGION_STEPS: { key: RegionStep; label: string }[] = [ @@ -20,6 +26,11 @@ export const REGION_STEPS: { key: RegionStep; label: string }[] = [ { key: 'dong', label: '읍/면/동' }, ] +export type RegionOption = { + code: string | null + name: string +} + export function formatRegionLabel(selection: RegionSelection): string { if (!selection.sido || selection.sido === '전국(전체)') return '지역 선택' if (!selection.sigungu || selection.sigungu === '전체') return selection.sido @@ -39,9 +50,18 @@ export function isRegionSelectionComplete(selection: RegionSelection): boolean { export function getRegionOptionsForStep( step: RegionStep, - filterOptions: Pick -): string[] { - if (step === 'sido') return ['전국(전체)', ...filterOptions.provinces] - if (step === 'sigungu') return ['전체', ...filterOptions.districts] - return ['전체', ...filterOptions.towns] + addresses: AddressItem[] +): RegionOption[] { + const allOption: RegionOption = + step === 'sido' + ? { code: null, name: '전국(전체)' } + : { code: null, name: '전체' } + + return [ + allOption, + ...addresses.map(address => ({ + code: address.code, + name: address.name, + })), + ] } diff --git a/src/features/job-lookup-map/types/posting.ts b/src/features/job-lookup-map/types/posting.ts index 7f7e90e4..f8f5fd23 100644 --- a/src/features/job-lookup-map/types/posting.ts +++ b/src/features/job-lookup-map/types/posting.ts @@ -16,11 +16,18 @@ export interface PostingDetailResponse { scrapped: boolean } -export interface PostingFilterOptions { - provinces: string[] - districts: string[] - towns: string[] - sortOptions: PostingSortOption[] +export interface AddressItem { + code: string + name: string +} + +export interface AddressesResponse { + addresses: AddressItem[] +} + +export interface PostingSortOption { + value: string + description: string } export interface Page { @@ -71,11 +78,6 @@ export interface ApplyPostingRequest { description: string } -export interface PostingSortOption { - value: string - description: string -} - /** `GET /app/users/me/postings/favorites` 응답의 공고 요약 */ export interface FavoritePostingSummary { id: number From 9c84577ee7a69cc8d4154e36776e5cf078df62d5 Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:21:46 +0900 Subject: [PATCH 10/15] =?UTF-8?q?fix=20:=20=ED=95=84=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx b/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx index 3a6508b9..cedbc5e7 100644 --- a/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx +++ b/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx @@ -85,7 +85,7 @@ export function AlbaFindCategoryBar({ sortValue, resolvedSortOptions, salaryFilter - ) + ).filter(item => mode === 'region' || item.id !== 'sort') const regionLabel = formatRegionLabel(regionSelection) const hasRegionSelected = regionLabel !== '지역 선택' const hasSortSelected = isSortFilterApplied(sortValue) @@ -117,7 +117,10 @@ export function AlbaFindCategoryBar({ type="button" role="tab" aria-selected={mode === 'nearby'} - onClick={() => onModeChange('nearby')} + onClick={() => { + setIsRegionDrawerOpen(false) + onModeChange('nearby') + }} className={`min-h-10 flex-1 rounded-lg typography-body01-semibold transition-colors ${ mode === 'nearby' ? 'border border-line-2 bg-white text-text-100 shadow-[0px_1px_4px_0px_rgba(0,0,0,0.12)]' From 10dfa6673b4201f2ef8db366ad9e3dd4ab1ad265 Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:48:00 +0900 Subject: [PATCH 11/15] =?UTF-8?q?fix=20:=20drawer=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=9C=84=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/job-lookup-map/api/posting.ts | 100 +++++++++++++----- .../common/FilterDrawerShell.tsx | 4 +- .../common/RegionSelectDrawer.tsx | 6 +- .../job-lookup-map/hooks/usePostingDetail.ts | 5 +- src/pages/user/job-lookup-map-apply/index.tsx | 4 +- .../user/job-lookup-map-detail/index.tsx | 4 +- 6 files changed, 86 insertions(+), 37 deletions(-) diff --git a/src/features/job-lookup-map/api/posting.ts b/src/features/job-lookup-map/api/posting.ts index dee9b4b3..3bc8e7fd 100644 --- a/src/features/job-lookup-map/api/posting.ts +++ b/src/features/job-lookup-map/api/posting.ts @@ -180,41 +180,89 @@ function unwrapPostingListBody(body: unknown): PostingListResponse { return normalizePostingListResponse(body) } -function isPostingDetailResponse( +function parseWorkspace( value: unknown -): value is PostingDetailResponse { - if (value === null || typeof value !== 'object') return false +): PostingDetailResponse['workspace'] | null { + if (value === null || typeof value !== 'object') return null + const workspace = value as Record + if (typeof workspace.id !== 'number') return null + + const businessName = + typeof workspace.businessName === 'string' + ? workspace.businessName + : typeof workspace.name === 'string' + ? workspace.name + : '' + + return { + id: workspace.id, + businessName, + name: typeof workspace.name === 'string' ? workspace.name : businessName, + latitude: typeof workspace.latitude === 'number' ? workspace.latitude : 0, + longitude: + typeof workspace.longitude === 'number' ? workspace.longitude : 0, + fullAddress: + typeof workspace.fullAddress === 'string' ? workspace.fullAddress : '', + town: typeof workspace.town === 'string' ? workspace.town : '', + } +} + +function parsePostingDetail(value: unknown): PostingDetailResponse | null { + if (value === null || typeof value !== 'object') return null const record = value as Record - const workspace = record.workspace - return ( - typeof record.id === 'number' && - typeof record.title === 'string' && - typeof record.description === 'string' && - typeof record.payAmount === 'number' && - typeof record.paymentType === 'string' && - typeof record.createdAt === 'string' && - typeof record.scrapped === 'boolean' && - Array.isArray(record.keywords) && - Array.isArray(record.schedules) && - workspace !== null && - typeof workspace === 'object' && - typeof (workspace as { id?: unknown }).id === 'number' - ) + const workspace = parseWorkspace(record.workspace) + + if ( + typeof record.id !== 'number' || + typeof record.title !== 'string' || + typeof record.payAmount !== 'number' || + workspace == null + ) { + return null + } + + return { + id: record.id, + title: record.title, + description: + typeof record.description === 'string' ? record.description : '', + payAmount: record.payAmount, + paymentType: + typeof record.paymentType === 'string' ? record.paymentType : 'HOURLY', + createdAt: typeof record.createdAt === 'string' ? record.createdAt : '', + keywords: Array.isArray(record.keywords) + ? (record.keywords as PostingDetailResponse['keywords']) + : [], + schedules: Array.isArray(record.schedules) + ? (record.schedules as PostingDetailResponse['schedules']) + : [], + workspace, + scrapped: typeof record.scrapped === 'boolean' ? record.scrapped : false, + } +} + +function unwrapPostingDetailPayload(payload: unknown): PostingDetailResponse { + if (payload !== null && typeof payload === 'object' && 'posting' in payload) { + const nested = (payload as { posting: unknown }).posting + const parsed = parsePostingDetail(nested) + if (parsed) return parsed + } + + const parsed = parsePostingDetail(payload) + if (parsed) return parsed + + throw new Error('공고 상세 응답 형식이 올바르지 않습니다.') } function unwrapPostingDetailBody(body: unknown): PostingDetailResponse { if (isCommonApiEnvelope(body)) { - if (!isPostingDetailResponse(body.data)) { - throw new Error('공고 상세 응답 형식이 올바르지 않습니다.') + if (body.data == null) { + throw new Error('공고 상세를 불러오지 못했습니다.') } - return body.data - } - - if (isPostingDetailResponse(body)) { - return body + return unwrapPostingDetailPayload(body.data) } - throw new Error('공고 상세를 불러오지 못했습니다.') + return unwrapPostingDetailPayload(body) } export async function fetchPostings( diff --git a/src/features/job-lookup-map/common/FilterDrawerShell.tsx b/src/features/job-lookup-map/common/FilterDrawerShell.tsx index 8a2d1e19..343c4a12 100644 --- a/src/features/job-lookup-map/common/FilterDrawerShell.tsx +++ b/src/features/job-lookup-map/common/FilterDrawerShell.tsx @@ -40,8 +40,8 @@ export function FilterDrawerShell({ - -
+ +
{open ? ( <>
diff --git a/src/features/job-lookup-map/common/RegionSelectDrawer.tsx b/src/features/job-lookup-map/common/RegionSelectDrawer.tsx index bbd980c9..8d2de5fb 100644 --- a/src/features/job-lookup-map/common/RegionSelectDrawer.tsx +++ b/src/features/job-lookup-map/common/RegionSelectDrawer.tsx @@ -237,7 +237,7 @@ function RegionSelectDrawerBody({ })}
-
+
{isLoading ? (

지역 정보를 불러오는 중… @@ -318,8 +318,8 @@ export function RegionSelectDrawer({ - -

+ +
{open ? ( fetchPostingDetail(postingId!), enabled: postingId != null && postingId > 0, + retry: false, }) - return { data, isPending, isError } + return { data, isLoading, isError, isFetching } } diff --git a/src/pages/user/job-lookup-map-apply/index.tsx b/src/pages/user/job-lookup-map-apply/index.tsx index 20855771..236e07de 100644 --- a/src/pages/user/job-lookup-map-apply/index.tsx +++ b/src/pages/user/job-lookup-map-apply/index.tsx @@ -110,7 +110,7 @@ export function JobLookupMapApplyPage() { const postingId = Number(postingIdParam) const idOk = Number.isFinite(postingId) && postingId > 0 - const { data, isPending, isError } = usePostingDetail( + const { data, isLoading, isError } = usePostingDetail( idOk ? postingId : undefined ) const [introduction, setIntroduction] = useState('') @@ -124,7 +124,7 @@ export function JobLookupMapApplyPage() { isError: isSubmitError, } = useApplyPosting() - const showLoading = idOk && isPending && !data + const showLoading = idOk && isLoading && !data const showError = idOk && isError && !data const showEmpty = !idOk diff --git a/src/pages/user/job-lookup-map-detail/index.tsx b/src/pages/user/job-lookup-map-detail/index.tsx index 717472b5..b35c8a88 100644 --- a/src/pages/user/job-lookup-map-detail/index.tsx +++ b/src/pages/user/job-lookup-map-detail/index.tsx @@ -36,7 +36,7 @@ export function JobLookupMapDetailPage() { const postingId = Number(postingIdParam) const idOk = Number.isFinite(postingId) && postingId > 0 - const { data, isPending, isError } = usePostingDetail( + const { data, isLoading, isError } = usePostingDetail( idOk ? postingId : undefined ) const { toggleFavorite, isPending: isFavoritePending } = @@ -124,7 +124,7 @@ export function JobLookupMapDetailPage() { )} - {idOk && isPending && !data && ( + {idOk && isLoading && !data && (

공고 정보를 불러오는 중… From c969a9b24d5932e9ca3a5c6b8b0927da565cd17c Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:51:30 +0900 Subject: [PATCH 12/15] =?UTF-8?q?fix=20:=20useAddFavoritePosting=20?= =?UTF-8?q?=ED=9B=85=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/useAddFavoritePosting.ts | 21 ------------------- 1 file changed, 21 deletions(-) delete mode 100644 src/features/job-lookup-map/hooks/useAddFavoritePosting.ts diff --git a/src/features/job-lookup-map/hooks/useAddFavoritePosting.ts b/src/features/job-lookup-map/hooks/useAddFavoritePosting.ts deleted file mode 100644 index 8ba5a8c6..00000000 --- a/src/features/job-lookup-map/hooks/useAddFavoritePosting.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { addFavoritePosting } from '@/features/job-lookup-map/api/posting' - -export function useAddFavoritePosting() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: (postingId: number) => addFavoritePosting(postingId), - onSuccess: (_, postingId) => { - void queryClient.invalidateQueries({ - queryKey: ['jobLookupMap', 'favoritePostings'], - }) - void queryClient.invalidateQueries({ - queryKey: ['jobLookupMap', 'postings'], - }) - void queryClient.invalidateQueries({ - queryKey: ['postingDetail', postingId], - }) - }, - }) -} From 3c44e0adce0b73b2d2864d73104e4c441bb6b8a7 Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:20:04 +0900 Subject: [PATCH 13/15] =?UTF-8?q?fix=20:=20=EC=A7=80=EC=97=AD=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=B0=BE=EA=B8=B0=20=ED=95=84=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?UI=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/icons/job-lookup-map/Close.svg | 3 + src/assets/icons/job-lookup-map/Filter.svg | 6 + .../icons/job-lookup-map/MappinMuted.svg | 11 + .../common/AlbaFindCategoryBar.tsx | 195 +++++--- .../common/AlbaFindFilteredEmptyState.tsx | 36 ++ .../common/FilterDrawerShell.tsx | 6 +- .../common/RegionModeFilterDrawer.tsx | 449 ++++++++++++++++++ .../job-lookup-map/lib/postingFilters.ts | 28 +- .../job-lookup-map/lib/regionOptions.ts | 21 + src/pages/user/job-lookup-map/index.tsx | 35 +- 10 files changed, 713 insertions(+), 77 deletions(-) create mode 100644 src/assets/icons/job-lookup-map/Close.svg create mode 100644 src/assets/icons/job-lookup-map/Filter.svg create mode 100644 src/assets/icons/job-lookup-map/MappinMuted.svg create mode 100644 src/features/job-lookup-map/common/AlbaFindFilteredEmptyState.tsx create mode 100644 src/features/job-lookup-map/common/RegionModeFilterDrawer.tsx diff --git a/src/assets/icons/job-lookup-map/Close.svg b/src/assets/icons/job-lookup-map/Close.svg new file mode 100644 index 00000000..b03b00c6 --- /dev/null +++ b/src/assets/icons/job-lookup-map/Close.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/job-lookup-map/Filter.svg b/src/assets/icons/job-lookup-map/Filter.svg new file mode 100644 index 00000000..8042bb5d --- /dev/null +++ b/src/assets/icons/job-lookup-map/Filter.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/job-lookup-map/MappinMuted.svg b/src/assets/icons/job-lookup-map/MappinMuted.svg new file mode 100644 index 00000000..5fc2955f --- /dev/null +++ b/src/assets/icons/job-lookup-map/MappinMuted.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx b/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx index cedbc5e7..e5002e45 100644 --- a/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx +++ b/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx @@ -1,11 +1,14 @@ -import { useState } from 'react' +import { forwardRef, useImperativeHandle, useState } from 'react' import ChevrondownIcon from '@/assets/icons/job-lookup-map/Chevrondown.svg?react' -import { RegionSelectDrawer } from '@/features/job-lookup-map/common/RegionSelectDrawer' +import CloseIcon from '@/assets/icons/job-lookup-map/Close.svg?react' +import FilterIcon from '@/assets/icons/job-lookup-map/Filter.svg?react' +import { RegionModeFilterDrawer } from '@/features/job-lookup-map/common/RegionModeFilterDrawer' import { SalarySelectDrawer } from '@/features/job-lookup-map/common/SalarySelectDrawer' import { SortSelectDrawer } from '@/features/job-lookup-map/common/SortSelectDrawer' import { DEFAULT_SORT_VALUE, EMPTY_SALARY_FILTER, + countActiveFilters, formatSalaryChipLabel, formatSortChipLabel, isSalaryFilterApplied, @@ -14,7 +17,8 @@ import { } from '@/features/job-lookup-map/lib/postingFilters' import { EMPTY_REGION_SELECTION, - formatRegionLabel, + formatRegionChipLabel, + hasRegionFilterApplied, type RegionSelection, } from '@/features/job-lookup-map/lib/regionOptions' @@ -31,78 +35,97 @@ type AlbaFindCategoryBarProps = { onSortChange?: (value: string) => void salaryFilter?: SalaryFilterSelection onSalaryChange?: (selection: SalaryFilterSelection) => void + regionFilterDrawerOpen?: boolean + onRegionFilterDrawerOpenChange?: (open: boolean) => void } -const FILTER_ITEMS: { id: AlbaFindFilterId; label: string }[] = [ - { id: 'sort', label: '지역 선택' }, - { id: 'distance', label: '최신순' }, - { id: 'salary', label: '전체' }, -] +export type AlbaFindCategoryBarRef = { + openFilters: () => void +} const FALLBACK_SORT_OPTIONS = [ { value: 'LATEST', description: '최신순' }, { value: 'PAY_AMOUNT', description: '급여순' }, ] -function getFilterItems( - regionSelection: RegionSelection, - sortValue: string, - sortOptions: { value: string; description: string }[], - salaryFilter: SalaryFilterSelection +export const AlbaFindCategoryBar = forwardRef< + AlbaFindCategoryBarRef, + AlbaFindCategoryBarProps +>(function AlbaFindCategoryBar( + { + mode, + onModeChange, + regionSelection = EMPTY_REGION_SELECTION, + onRegionChange, + sortValue = DEFAULT_SORT_VALUE, + onSortChange, + salaryFilter = EMPTY_SALARY_FILTER, + onSalaryChange, + regionFilterDrawerOpen, + onRegionFilterDrawerOpenChange, + }, + ref ) { - return FILTER_ITEMS.map(item => { - if (item.id === 'sort') { - return { ...item, label: formatRegionLabel(regionSelection) } - } - if (item.id === 'distance') { - return { - ...item, - label: formatSortChipLabel(sortValue, sortOptions), - } - } - return { ...item, label: formatSalaryChipLabel(salaryFilter) } - }) -} - -export function AlbaFindCategoryBar({ - mode, - onModeChange, - regionSelection = EMPTY_REGION_SELECTION, - onRegionChange, - sortValue = DEFAULT_SORT_VALUE, - onSortChange, - salaryFilter = EMPTY_SALARY_FILTER, - onSalaryChange, -}: AlbaFindCategoryBarProps) { const resolvedSortOptions = FALLBACK_SORT_OPTIONS - const [isRegionDrawerOpen, setIsRegionDrawerOpen] = useState(false) + const [internalRegionFilterDrawerOpen, setInternalRegionFilterDrawerOpen] = + useState(false) const [isSortDrawerOpen, setIsSortDrawerOpen] = useState(false) const [isSalaryDrawerOpen, setIsSalaryDrawerOpen] = useState(false) - const filterItems = getFilterItems( + const isRegionFilterDrawerOpen = + regionFilterDrawerOpen ?? internalRegionFilterDrawerOpen + const setIsRegionFilterDrawerOpen = + onRegionFilterDrawerOpenChange ?? setInternalRegionFilterDrawerOpen + + useImperativeHandle( + ref, + () => ({ + openFilters: () => { + if (mode === 'region') { + setIsRegionFilterDrawerOpen(true) + return + } + if (isSalaryFilterApplied(salaryFilter)) { + setIsSalaryDrawerOpen(true) + return + } + setIsSortDrawerOpen(true) + }, + }), + [mode, salaryFilter, setIsRegionFilterDrawerOpen] + ) + + const activeFilterCount = countActiveFilters({ + mode, regionSelection, sortValue, - resolvedSortOptions, - salaryFilter - ).filter(item => mode === 'region' || item.id !== 'sort') - const regionLabel = formatRegionLabel(regionSelection) - const hasRegionSelected = regionLabel !== '지역 선택' + salaryFilter, + }) + const regionChipLabel = formatRegionChipLabel(regionSelection) const hasSortSelected = isSortFilterApplied(sortValue) const hasSalarySelected = isSalaryFilterApplied(salaryFilter) - const handleFilterClick = (id: AlbaFindFilterId) => { - if (id === 'sort') { - setIsRegionDrawerOpen(true) - return - } + const nearbyFilterItems = [ + { + id: 'distance' as const, + label: formatSortChipLabel(sortValue, resolvedSortOptions), + active: hasSortSelected, + }, + { + id: 'salary' as const, + label: formatSalaryChipLabel(salaryFilter), + active: hasSalarySelected, + }, + ] + + const handleNearbyFilterClick = (id: AlbaFindFilterId) => { if (id === 'distance') { setIsSortDrawerOpen(true) return } if (id === 'salary') { setIsSalaryDrawerOpen(true) - return } } @@ -118,7 +141,7 @@ export function AlbaFindCategoryBar({ role="tab" aria-selected={mode === 'nearby'} onClick={() => { - setIsRegionDrawerOpen(false) + setIsRegionFilterDrawerOpen(false) onModeChange('nearby') }} className={`min-h-10 flex-1 rounded-lg typography-body01-semibold transition-colors ${ @@ -144,15 +167,39 @@ export function AlbaFindCategoryBar({

-
- {filterItems.map(({ id, label }, index) => { - const active = - id === 'sort' - ? hasRegionSelected - : id === 'distance' - ? hasSortSelected - : hasSalarySelected - return ( + {mode === 'region' ? ( +
+ + + {hasRegionFilterApplied(regionSelection) && regionChipLabel ? ( + + {regionChipLabel} + + + ) : null} +
+ ) : ( +
+ {nearbyFilterItems.map(({ id, label, active }, index) => (
{index === 1 ? (
handleFilterClick(id)} + onClick={() => handleNearbyFilterClick(id)} className={`inline-flex h-[26px] min-w-0 items-center gap-1 rounded-full px-3 transition-colors ${ active ? 'bg-main typography-body03-semibold text-text-100' @@ -175,16 +222,24 @@ export function AlbaFindCategoryBar({ />
- ) - })} -
+ ))} +
+ )}
- onRegionChange?.(selection)} + { + onRegionChange?.(nextRegion) + onSortChange?.(nextSort) + onSalaryChange?.(nextSalary) + }} /> ) -} +}) diff --git a/src/features/job-lookup-map/common/AlbaFindFilteredEmptyState.tsx b/src/features/job-lookup-map/common/AlbaFindFilteredEmptyState.tsx new file mode 100644 index 00000000..84b88f1b --- /dev/null +++ b/src/features/job-lookup-map/common/AlbaFindFilteredEmptyState.tsx @@ -0,0 +1,36 @@ +import MappinMutedIcon from '@/assets/icons/job-lookup-map/MappinMuted.svg?react' + +type AlbaFindFilteredEmptyStateProps = { + title?: string + description?: string + actionLabel: string + onAction: () => void +} + +export function AlbaFindFilteredEmptyState({ + title = '이 지역에 공고가 없어요', + description = '다른 지역을 선택하거나 조건을 바꿔보세요', + actionLabel, + onAction, +}: AlbaFindFilteredEmptyStateProps) { + return ( +
+
+ +
+

+ {title} +

+

+ {description} +

+ +
+ ) +} diff --git a/src/features/job-lookup-map/common/FilterDrawerShell.tsx b/src/features/job-lookup-map/common/FilterDrawerShell.tsx index 343c4a12..4183e06c 100644 --- a/src/features/job-lookup-map/common/FilterDrawerShell.tsx +++ b/src/features/job-lookup-map/common/FilterDrawerShell.tsx @@ -47,7 +47,7 @@ export function FilterDrawerShell({
- + {title}
) diff --git a/src/features/job-lookup-map/common/RegionModeFilterDrawer.tsx b/src/features/job-lookup-map/common/RegionModeFilterDrawer.tsx new file mode 100644 index 00000000..e3d255c6 --- /dev/null +++ b/src/features/job-lookup-map/common/RegionModeFilterDrawer.tsx @@ -0,0 +1,449 @@ +import { useMemo, useState } from 'react' + +import { ChevronRightIcon } from '@/assets/icons/ChevronRightIcon' +import { + FilterDrawerApplyFooter, + FilterDrawerShell, +} from '@/features/job-lookup-map/common/FilterDrawerShell' +import { useAddresses } from '@/features/job-lookup-map/hooks/useAddresses' +import { + DEFAULT_SORT_VALUE, + EMPTY_SALARY_FILTER, + formatSalaryInput, + parseSalaryInput, + type SalaryFilterSelection, +} from '@/features/job-lookup-map/lib/postingFilters' +import { + EMPTY_REGION_SELECTION, + REGION_STEPS, + getRegionOptionsForStep, + type RegionOption, + type RegionSelection, + type RegionStep, +} from '@/features/job-lookup-map/lib/regionOptions' + +const SORT_OPTIONS = [ + { value: 'LATEST', label: '최신순' }, + { value: 'PAY_AMOUNT', label: '급여순' }, +] as const + +type RegionModeFilterValues = { + regionSelection: RegionSelection + sortValue: string + salaryFilter: SalaryFilterSelection +} + +type RegionModeFilterDrawerProps = { + open: boolean + onOpenChange: (open: boolean) => void + value: RegionModeFilterValues + onApply: (values: RegionModeFilterValues) => void +} + +function FilterChip({ + selected, + label, + onClick, +}: { + selected: boolean + label: string + onClick: () => void +}) { + return ( + + ) +} + +function getStepLabel(step: RegionStep, draft: RegionSelection): string { + if (step === 'sido') { + return draft.sido && draft.sido !== '전국(전체)' ? draft.sido : '시/도' + } + if (step === 'sigungu') { + return draft.sigungu && draft.sigungu !== '전체' + ? draft.sigungu + : '시/군/구' + } + return draft.dong && draft.dong !== '전체' ? draft.dong : '읍/면/동' +} + +function RegionModeFilterDrawerBody({ + value, + onOpenChange, + onApply, +}: { + value: RegionModeFilterValues + onOpenChange: (open: boolean) => void + onApply: (values: RegionModeFilterValues) => void +}) { + const [regionDraft, setRegionDraft] = useState( + value.regionSelection + ) + const [sortDraft, setSortDraft] = useState(value.sortValue) + const [salaryDraft, setSalaryDraft] = useState(value.salaryFilter) + const [minInput, setMinInput] = useState( + formatSalaryInput(value.salaryFilter.min) + ) + const [maxInput, setMaxInput] = useState( + formatSalaryInput(value.salaryFilter.max) + ) + const [regionStep, setRegionStep] = useState('sido') + + const parentCode = + regionStep === 'sigungu' + ? (regionDraft.sidoCode ?? undefined) + : regionStep === 'dong' + ? (regionDraft.sigunguCode ?? undefined) + : undefined + + const addressesEnabled = + regionStep === 'sido' || + (regionStep === 'sigungu' && regionDraft.sidoCode != null) || + (regionStep === 'dong' && regionDraft.sigunguCode != null) + + const { addresses, isLoading, isError } = useAddresses( + parentCode, + addressesEnabled + ) + + const regionOptions = useMemo( + () => getRegionOptionsForStep(regionStep, addresses), + [regionStep, addresses] + ) + + const selectedForStep = + regionStep === 'sido' + ? regionDraft.sido + : regionStep === 'sigungu' + ? regionDraft.sigungu + : regionDraft.dong + + const canGoToStep = (target: RegionStep) => { + if (target === 'sido') return true + if (target === 'sigungu') { + return regionDraft.sido != null && regionDraft.sido !== '전국(전체)' + } + return ( + regionDraft.sido != null && + regionDraft.sido !== '전국(전체)' && + regionDraft.sigungu != null && + regionDraft.sigungu !== '전체' + ) + } + + const handleRegionSelect = (option: RegionOption) => { + if (regionStep === 'sido') { + if (option.name === '전국(전체)') { + setRegionDraft({ + ...EMPTY_REGION_SELECTION, + sido: '전국(전체)', + sigungu: '전체', + dong: '전체', + }) + return + } + + setRegionDraft({ + sido: option.name, + sidoCode: option.code, + sigungu: null, + sigunguCode: null, + dong: null, + dongCode: null, + }) + setRegionStep('sigungu') + return + } + + if (regionStep === 'sigungu') { + if (option.name === '전체') { + setRegionDraft({ + ...regionDraft, + sigungu: '전체', + sigunguCode: null, + dong: '전체', + dongCode: null, + }) + return + } + + setRegionDraft({ + ...regionDraft, + sigungu: option.name, + sigunguCode: option.code, + dong: null, + dongCode: null, + }) + setRegionStep('dong') + return + } + + if (option.name === '전체') { + setRegionDraft({ + ...regionDraft, + dong: '전체', + dongCode: null, + }) + return + } + + setRegionDraft({ + ...regionDraft, + dong: option.name, + dongCode: option.code, + }) + } + + const handleSalaryPreset = (preset: 'all' | 'custom') => { + if (preset === 'all') { + setSalaryDraft(EMPTY_SALARY_FILTER) + setMinInput('') + setMaxInput('') + return + } + + setSalaryDraft(prev => ({ + preset: 'custom', + min: prev.min, + max: prev.max, + })) + } + + const handleMinChange = (raw: string) => { + const min = parseSalaryInput(raw) + setMinInput(min != null ? formatSalaryInput(min) : '') + setSalaryDraft(prev => ({ + preset: 'custom', + min, + max: prev.max, + })) + } + + const handleMaxChange = (raw: string) => { + const max = parseSalaryInput(raw) + setMaxInput(max != null ? formatSalaryInput(max) : '') + setSalaryDraft(prev => ({ + preset: 'custom', + min: prev.min, + max, + })) + } + + const handleReset = () => { + setRegionDraft(EMPTY_REGION_SELECTION) + setRegionStep('sido') + setSortDraft(DEFAULT_SORT_VALUE) + setSalaryDraft(EMPTY_SALARY_FILTER) + setMinInput('') + setMaxInput('') + } + + const handleApply = () => { + let nextSalary = salaryDraft + if ( + salaryDraft.min != null && + salaryDraft.max != null && + salaryDraft.min > salaryDraft.max + ) { + nextSalary = { + ...salaryDraft, + min: salaryDraft.max, + max: salaryDraft.min, + } + } + + onApply({ + regionSelection: regionDraft, + sortValue: sortDraft, + salaryFilter: nextSalary, + }) + onOpenChange(false) + } + + const isCustomSalary = salaryDraft.preset === 'custom' + + return ( + <> +
+
+

지역

+ +
+ {REGION_STEPS.map((item, index) => { + const active = regionStep === item.key + const enabled = canGoToStep(item.key) + return ( +
+ {index > 0 ? ( + + ) : null} + +
+ ) + })} +
+ +
+ {isLoading ? ( +

+ 지역 정보를 불러오는 중… +

+ ) : isError ? ( +

+ 지역 정보를 불러오지 못했습니다. +

+ ) : ( + regionOptions.map(option => ( + handleRegionSelect(option)} + /> + )) + )} +
+
+ +
+

정렬

+
+ {SORT_OPTIONS.map(option => ( + setSortDraft(option.value)} + /> + ))} +
+
+ +
+

급여

+
+ handleSalaryPreset('all')} + /> + handleSalaryPreset('custom')} + /> +
+ + {isCustomSalary ? ( +
+

+ 최소 / 최대 시급 +

+
+ + + ~ + + +
+
+ ) : null} +
+
+ + + + ) +} + +export function RegionModeFilterDrawer({ + open, + onOpenChange, + value, + onApply, +}: RegionModeFilterDrawerProps) { + const resetKey = `${value.regionSelection.sido ?? ''}-${value.regionSelection.sigungu ?? ''}-${value.regionSelection.dong ?? ''}-${value.sortValue}-${value.salaryFilter.preset}-${value.salaryFilter.min ?? ''}-${value.salaryFilter.max ?? ''}` + + return ( + + {open ? ( + + ) : null} + + ) +} diff --git a/src/features/job-lookup-map/lib/postingFilters.ts b/src/features/job-lookup-map/lib/postingFilters.ts index fe8d1c00..f5f2174b 100644 --- a/src/features/job-lookup-map/lib/postingFilters.ts +++ b/src/features/job-lookup-map/lib/postingFilters.ts @@ -1,5 +1,8 @@ import type { RegionSelection } from '@/features/job-lookup-map/lib/regionOptions' -import { isRegionSelectionComplete } from '@/features/job-lookup-map/lib/regionOptions' +import { + hasRegionFilterApplied, + isRegionSelectionComplete, +} from '@/features/job-lookup-map/lib/regionOptions' export type AlbaFindMode = 'nearby' | 'region' @@ -78,6 +81,29 @@ export function isSortFilterApplied(value: string): boolean { return value !== DEFAULT_SORT_VALUE } +export function countActiveFilters(params: { + regionSelection: RegionSelection + sortValue: string + salaryFilter: SalaryFilterSelection + mode: AlbaFindMode +}): number { + const { regionSelection, sortValue, salaryFilter, mode } = params + let count = 0 + if (mode === 'region' && hasRegionFilterApplied(regionSelection)) count += 1 + if (isSortFilterApplied(sortValue)) count += 1 + if (isSalaryFilterApplied(salaryFilter)) count += 1 + return count +} + +export function isListFilterApplied(params: { + mode: AlbaFindMode + regionSelection: RegionSelection + sortValue: string + salaryFilter: SalaryFilterSelection +}): boolean { + return countActiveFilters(params) > 0 +} + export function parseSalaryInput(raw: string): number | null { const digits = raw.replace(/[^\d]/g, '') if (!digits) return null diff --git a/src/features/job-lookup-map/lib/regionOptions.ts b/src/features/job-lookup-map/lib/regionOptions.ts index 4c2e70b9..edbc8961 100644 --- a/src/features/job-lookup-map/lib/regionOptions.ts +++ b/src/features/job-lookup-map/lib/regionOptions.ts @@ -40,6 +40,27 @@ export function formatRegionLabel(selection: RegionSelection): string { return `${selection.sigungu} ${selection.dong}` } +export function formatRegionChipLabel( + selection: RegionSelection +): string | null { + if (!selection.sido || selection.sido === '전국(전체)') return null + if (!selection.sigungu || selection.sigungu === '전체') { + return selection.sido + } + if (!selection.dong || selection.dong === '전체') { + return `${selection.sido} ${selection.sigungu}` + } + return `${selection.sido} ${selection.sigungu}` +} + +export function hasRegionFilterApplied(selection: RegionSelection): boolean { + return ( + selection.sido != null && + selection.sido !== '전국(전체)' && + isRegionSelectionComplete(selection) + ) +} + export function isRegionSelectionComplete(selection: RegionSelection): boolean { return ( selection.sido != null && diff --git a/src/pages/user/job-lookup-map/index.tsx b/src/pages/user/job-lookup-map/index.tsx index e5d4a09c..7c0d97c7 100644 --- a/src/pages/user/job-lookup-map/index.tsx +++ b/src/pages/user/job-lookup-map/index.tsx @@ -9,7 +9,11 @@ import { } from 'react' import { generatePath, useNavigate } from 'react-router-dom' import { animate, motion, useMotionValue } from 'framer-motion' -import { AlbaFindCategoryBar } from '@/features/job-lookup-map/common/AlbaFindCategoryBar' +import { + AlbaFindCategoryBar, + type AlbaFindCategoryBarRef, +} from '@/features/job-lookup-map/common/AlbaFindCategoryBar' +import { AlbaFindFilteredEmptyState } from '@/features/job-lookup-map/common/AlbaFindFilteredEmptyState' import { ROUTES } from '@/shared/constants/routes' import type { AlbaFindMode } from '@/features/job-lookup-map/common/AlbaFindCategoryBar' import { AlbaFindList } from '@/features/job-lookup-map/common/AlbaFindList' @@ -30,6 +34,7 @@ import { DEFAULT_SORT_VALUE, EMPTY_SALARY_FILTER, buildPostingsListFilters, + isListFilterApplied, type SalaryFilterSelection, } from '@/features/job-lookup-map/lib/postingFilters' import { @@ -64,6 +69,7 @@ export function JobLookupMapPage() { const [searchQuery, setSearchQuery] = useState('') const [searchList, setSearchList] = useState(null) const loadMoreRef = useRef(null) + const categoryBarRef = useRef(null) const hasSetInitialSheetYRef = useRef(false) const y = useMotionValue(0) @@ -325,6 +331,7 @@ export function JobLookupMapPage() {
) : displayedPostings.length === 0 ? ( -

- 조건에 맞는 공고가 없습니다. -

+ !isSearchActive && + isListFilterApplied({ + mode, + regionSelection, + sortValue, + salaryFilter, + }) ? ( + categoryBarRef.current?.openFilters()} + /> + ) : ( +

+ 조건에 맞는 공고가 없습니다. +

+ ) ) : null} {displayedPostings.map(posting => { const base = postingToAlbaboxProps(posting) From c82d316d299420ed443afb92683ef69ca0e65854 Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:22:55 +0900 Subject: [PATCH 14/15] =?UTF-8?q?fix=20:=20=EC=A3=BC=EB=B3=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=B0=BE=EA=B8=B0=20=ED=95=84=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/AlbaFindCategoryBar.tsx | 185 ++++++------- .../common/NearbyModeFilterDrawer.tsx | 257 ++++++++++++++++++ 2 files changed, 347 insertions(+), 95 deletions(-) create mode 100644 src/features/job-lookup-map/common/NearbyModeFilterDrawer.tsx diff --git a/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx b/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx index e5002e45..bfcf616d 100644 --- a/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx +++ b/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx @@ -1,10 +1,8 @@ import { forwardRef, useImperativeHandle, useState } from 'react' -import ChevrondownIcon from '@/assets/icons/job-lookup-map/Chevrondown.svg?react' import CloseIcon from '@/assets/icons/job-lookup-map/Close.svg?react' import FilterIcon from '@/assets/icons/job-lookup-map/Filter.svg?react' +import { NearbyModeFilterDrawer } from '@/features/job-lookup-map/common/NearbyModeFilterDrawer' import { RegionModeFilterDrawer } from '@/features/job-lookup-map/common/RegionModeFilterDrawer' -import { SalarySelectDrawer } from '@/features/job-lookup-map/common/SalarySelectDrawer' -import { SortSelectDrawer } from '@/features/job-lookup-map/common/SortSelectDrawer' import { DEFAULT_SORT_VALUE, EMPTY_SALARY_FILTER, @@ -24,8 +22,6 @@ import { export type AlbaFindMode = 'nearby' | 'region' -export type AlbaFindFilterId = 'sort' | 'distance' | 'salary' - type AlbaFindCategoryBarProps = { mode: AlbaFindMode onModeChange: (mode: AlbaFindMode) => void @@ -48,6 +44,52 @@ const FALLBACK_SORT_OPTIONS = [ { value: 'PAY_AMOUNT', description: '급여순' }, ] +function FilterIconButton({ + activeFilterCount, + onClick, +}: { + activeFilterCount: number + onClick: () => void +}) { + return ( + + ) +} + +function ActiveFilterChip({ + label, + onRemove, +}: { + label: string + onRemove: () => void +}) { + return ( + + {label} + + + ) +} + export const AlbaFindCategoryBar = forwardRef< AlbaFindCategoryBarRef, AlbaFindCategoryBarProps @@ -70,8 +112,8 @@ export const AlbaFindCategoryBar = forwardRef< const [internalRegionFilterDrawerOpen, setInternalRegionFilterDrawerOpen] = useState(false) - const [isSortDrawerOpen, setIsSortDrawerOpen] = useState(false) - const [isSalaryDrawerOpen, setIsSalaryDrawerOpen] = useState(false) + const [isNearbyFilterDrawerOpen, setIsNearbyFilterDrawerOpen] = + useState(false) const isRegionFilterDrawerOpen = regionFilterDrawerOpen ?? internalRegionFilterDrawerOpen @@ -86,14 +128,10 @@ export const AlbaFindCategoryBar = forwardRef< setIsRegionFilterDrawerOpen(true) return } - if (isSalaryFilterApplied(salaryFilter)) { - setIsSalaryDrawerOpen(true) - return - } - setIsSortDrawerOpen(true) + setIsNearbyFilterDrawerOpen(true) }, }), - [mode, salaryFilter, setIsRegionFilterDrawerOpen] + [mode, setIsRegionFilterDrawerOpen] ) const activeFilterCount = countActiveFilters({ @@ -105,29 +143,8 @@ export const AlbaFindCategoryBar = forwardRef< const regionChipLabel = formatRegionChipLabel(regionSelection) const hasSortSelected = isSortFilterApplied(sortValue) const hasSalarySelected = isSalaryFilterApplied(salaryFilter) - - const nearbyFilterItems = [ - { - id: 'distance' as const, - label: formatSortChipLabel(sortValue, resolvedSortOptions), - active: hasSortSelected, - }, - { - id: 'salary' as const, - label: formatSalaryChipLabel(salaryFilter), - active: hasSalarySelected, - }, - ] - - const handleNearbyFilterClick = (id: AlbaFindFilterId) => { - if (id === 'distance') { - setIsSortDrawerOpen(true) - return - } - if (id === 'salary') { - setIsSalaryDrawerOpen(true) - } - } + const sortChipLabel = formatSortChipLabel(sortValue, resolvedSortOptions) + const salaryChipLabel = formatSalaryChipLabel(salaryFilter) return ( <> @@ -156,7 +173,10 @@ export const AlbaFindCategoryBar = forwardRef< type="button" role="tab" aria-selected={mode === 'region'} - onClick={() => onModeChange('region')} + onClick={() => { + setIsNearbyFilterDrawerOpen(false) + onModeChange('region') + }} className={`min-h-10 flex-1 rounded-lg typography-body01-semibold transition-colors ${ mode === 'region' ? 'border border-line-2 bg-white text-text-100 shadow-[0px_1px_4px_0px_rgba(0,0,0,0.12)]' @@ -169,60 +189,38 @@ export const AlbaFindCategoryBar = forwardRef< {mode === 'region' ? (
- + /> {hasRegionFilterApplied(regionSelection) && regionChipLabel ? ( - - {regionChipLabel} - - + onRegionChange?.(EMPTY_REGION_SELECTION)} + /> ) : null}
) : (
- {nearbyFilterItems.map(({ id, label, active }, index) => ( -
- {index === 1 ? ( -
- ) : null} - -
- ))} + setIsNearbyFilterDrawerOpen(true)} + /> + + {hasSortSelected ? ( + onSortChange?.(DEFAULT_SORT_VALUE)} + /> + ) : null} + + {hasSalarySelected ? ( + onSalaryChange?.(EMPTY_SALARY_FILTER)} + /> + ) : null}
)}
@@ -241,17 +239,14 @@ export const AlbaFindCategoryBar = forwardRef< onSalaryChange?.(nextSalary) }} /> - onSortChange?.(value)} - /> - onSalaryChange?.(selection)} + { + onSortChange?.(nextSort) + onSalaryChange?.(nextSalary) + }} /> ) diff --git a/src/features/job-lookup-map/common/NearbyModeFilterDrawer.tsx b/src/features/job-lookup-map/common/NearbyModeFilterDrawer.tsx new file mode 100644 index 00000000..1edda7cc --- /dev/null +++ b/src/features/job-lookup-map/common/NearbyModeFilterDrawer.tsx @@ -0,0 +1,257 @@ +import { useState } from 'react' + +import { + FilterDrawerApplyFooter, + FilterDrawerShell, +} from '@/features/job-lookup-map/common/FilterDrawerShell' +import { + DEFAULT_SORT_VALUE, + EMPTY_SALARY_FILTER, + formatSalaryInput, + parseSalaryInput, + type SalaryFilterSelection, +} from '@/features/job-lookup-map/lib/postingFilters' + +const SORT_OPTIONS = [ + { value: 'LATEST', label: '최신순' }, + { value: 'PAY_AMOUNT', label: '급여순' }, +] as const + +type NearbyModeFilterValues = { + sortValue: string + salaryFilter: SalaryFilterSelection +} + +type NearbyModeFilterDrawerProps = { + open: boolean + onOpenChange: (open: boolean) => void + value: NearbyModeFilterValues + onApply: (values: NearbyModeFilterValues) => void +} + +function FilterChip({ + selected, + label, + onClick, +}: { + selected: boolean + label: string + onClick: () => void +}) { + return ( + + ) +} + +function NearbyModeFilterDrawerBody({ + value, + onOpenChange, + onApply, +}: { + value: NearbyModeFilterValues + onOpenChange: (open: boolean) => void + onApply: (values: NearbyModeFilterValues) => void +}) { + const [sortDraft, setSortDraft] = useState(value.sortValue) + const [salaryDraft, setSalaryDraft] = useState(value.salaryFilter) + const [minInput, setMinInput] = useState( + formatSalaryInput(value.salaryFilter.min) + ) + const [maxInput, setMaxInput] = useState( + formatSalaryInput(value.salaryFilter.max) + ) + + const handleSalaryPreset = (preset: 'all' | 'custom') => { + if (preset === 'all') { + setSalaryDraft(EMPTY_SALARY_FILTER) + setMinInput('') + setMaxInput('') + return + } + + setSalaryDraft(prev => ({ + preset: 'custom', + min: prev.min, + max: prev.max, + })) + } + + const handleMinChange = (raw: string) => { + const min = parseSalaryInput(raw) + setMinInput(min != null ? formatSalaryInput(min) : '') + setSalaryDraft(prev => ({ + preset: 'custom', + min, + max: prev.max, + })) + } + + const handleMaxChange = (raw: string) => { + const max = parseSalaryInput(raw) + setMaxInput(max != null ? formatSalaryInput(max) : '') + setSalaryDraft(prev => ({ + preset: 'custom', + min: prev.min, + max, + })) + } + + const handleReset = () => { + setSortDraft(DEFAULT_SORT_VALUE) + setSalaryDraft(EMPTY_SALARY_FILTER) + setMinInput('') + setMaxInput('') + } + + const handleApply = () => { + let nextSalary = salaryDraft + if ( + salaryDraft.min != null && + salaryDraft.max != null && + salaryDraft.min > salaryDraft.max + ) { + nextSalary = { + ...salaryDraft, + min: salaryDraft.max, + max: salaryDraft.min, + } + } + + onApply({ + sortValue: sortDraft, + salaryFilter: nextSalary, + }) + onOpenChange(false) + } + + const isCustomSalary = salaryDraft.preset === 'custom' + + return ( + <> +
+
+

정렬

+
+ {SORT_OPTIONS.map(option => ( + setSortDraft(option.value)} + /> + ))} +
+
+ +
+

급여

+
+ handleSalaryPreset('all')} + /> + handleSalaryPreset('custom')} + /> +
+ + {isCustomSalary ? ( +
+

+ 최소 / 최대 시급 +

+
+ + + ~ + + +
+
+ ) : null} +
+
+ + + + ) +} + +export function NearbyModeFilterDrawer({ + open, + onOpenChange, + value, + onApply, +}: NearbyModeFilterDrawerProps) { + const resetKey = `${value.sortValue}-${value.salaryFilter.preset}-${value.salaryFilter.min ?? ''}-${value.salaryFilter.max ?? ''}` + + return ( + + {open ? ( + + ) : null} + + ) +} From 84b2e5d14ec0357decab8948d603201f46c97996 Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:30:55 +0900 Subject: [PATCH 15/15] =?UTF-8?q?fix=20:=20=ED=95=84=ED=84=B0=20UI=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=ED=99=94=20&=20=EB=AF=B8?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/AlbaFindCategoryBar.tsx | 22 +- .../job-lookup-map/common/FilterChip.tsx | 21 ++ .../common/FilterDrawerShell.tsx | 13 - .../common/NearbyModeFilterDrawer.tsx | 210 ++--------- .../common/RegionModeFilterDrawer.tsx | 206 ++--------- .../common/RegionSelectDrawer.tsx | 336 ------------------ .../common/SalarySelectDrawer.tsx | 178 ---------- .../common/SortSalaryFilterSections.tsx | 110 ++++++ .../common/SortSelectDrawer.tsx | 99 ------ .../hooks/useSortSalaryFilterDraft.ts | 96 +++++ .../job-lookup-map/lib/postingFilters.ts | 26 +- 11 files changed, 294 insertions(+), 1023 deletions(-) create mode 100644 src/features/job-lookup-map/common/FilterChip.tsx delete mode 100644 src/features/job-lookup-map/common/RegionSelectDrawer.tsx delete mode 100644 src/features/job-lookup-map/common/SalarySelectDrawer.tsx create mode 100644 src/features/job-lookup-map/common/SortSalaryFilterSections.tsx delete mode 100644 src/features/job-lookup-map/common/SortSelectDrawer.tsx create mode 100644 src/features/job-lookup-map/hooks/useSortSalaryFilterDraft.ts diff --git a/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx b/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx index bfcf616d..528f03e5 100644 --- a/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx +++ b/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx @@ -31,19 +31,12 @@ type AlbaFindCategoryBarProps = { onSortChange?: (value: string) => void salaryFilter?: SalaryFilterSelection onSalaryChange?: (selection: SalaryFilterSelection) => void - regionFilterDrawerOpen?: boolean - onRegionFilterDrawerOpenChange?: (open: boolean) => void } export type AlbaFindCategoryBarRef = { openFilters: () => void } -const FALLBACK_SORT_OPTIONS = [ - { value: 'LATEST', description: '최신순' }, - { value: 'PAY_AMOUNT', description: '급여순' }, -] - function FilterIconButton({ activeFilterCount, onClick, @@ -103,23 +96,14 @@ export const AlbaFindCategoryBar = forwardRef< onSortChange, salaryFilter = EMPTY_SALARY_FILTER, onSalaryChange, - regionFilterDrawerOpen, - onRegionFilterDrawerOpenChange, }, ref ) { - const resolvedSortOptions = FALLBACK_SORT_OPTIONS - - const [internalRegionFilterDrawerOpen, setInternalRegionFilterDrawerOpen] = + const [isRegionFilterDrawerOpen, setIsRegionFilterDrawerOpen] = useState(false) const [isNearbyFilterDrawerOpen, setIsNearbyFilterDrawerOpen] = useState(false) - const isRegionFilterDrawerOpen = - regionFilterDrawerOpen ?? internalRegionFilterDrawerOpen - const setIsRegionFilterDrawerOpen = - onRegionFilterDrawerOpenChange ?? setInternalRegionFilterDrawerOpen - useImperativeHandle( ref, () => ({ @@ -131,7 +115,7 @@ export const AlbaFindCategoryBar = forwardRef< setIsNearbyFilterDrawerOpen(true) }, }), - [mode, setIsRegionFilterDrawerOpen] + [mode] ) const activeFilterCount = countActiveFilters({ @@ -143,7 +127,7 @@ export const AlbaFindCategoryBar = forwardRef< const regionChipLabel = formatRegionChipLabel(regionSelection) const hasSortSelected = isSortFilterApplied(sortValue) const hasSalarySelected = isSalaryFilterApplied(salaryFilter) - const sortChipLabel = formatSortChipLabel(sortValue, resolvedSortOptions) + const sortChipLabel = formatSortChipLabel(sortValue) const salaryChipLabel = formatSalaryChipLabel(salaryFilter) return ( diff --git a/src/features/job-lookup-map/common/FilterChip.tsx b/src/features/job-lookup-map/common/FilterChip.tsx new file mode 100644 index 00000000..2af76c11 --- /dev/null +++ b/src/features/job-lookup-map/common/FilterChip.tsx @@ -0,0 +1,21 @@ +type FilterChipProps = { + selected: boolean + label: string + onClick: () => void +} + +export function FilterChip({ selected, label, onClick }: FilterChipProps) { + return ( + + ) +} diff --git a/src/features/job-lookup-map/common/FilterDrawerShell.tsx b/src/features/job-lookup-map/common/FilterDrawerShell.tsx index 4183e06c..422c1277 100644 --- a/src/features/job-lookup-map/common/FilterDrawerShell.tsx +++ b/src/features/job-lookup-map/common/FilterDrawerShell.tsx @@ -120,16 +120,3 @@ export function FilterDrawerApplyFooter({
) } - -export function RadioIndicator({ selected }: { selected: boolean }) { - return ( - - {selected ? : null} - - ) -} diff --git a/src/features/job-lookup-map/common/NearbyModeFilterDrawer.tsx b/src/features/job-lookup-map/common/NearbyModeFilterDrawer.tsx index 1edda7cc..84dbac62 100644 --- a/src/features/job-lookup-map/common/NearbyModeFilterDrawer.tsx +++ b/src/features/job-lookup-map/common/NearbyModeFilterDrawer.tsx @@ -1,21 +1,10 @@ -import { useState } from 'react' - import { FilterDrawerApplyFooter, FilterDrawerShell, } from '@/features/job-lookup-map/common/FilterDrawerShell' -import { - DEFAULT_SORT_VALUE, - EMPTY_SALARY_FILTER, - formatSalaryInput, - parseSalaryInput, - type SalaryFilterSelection, -} from '@/features/job-lookup-map/lib/postingFilters' - -const SORT_OPTIONS = [ - { value: 'LATEST', label: '최신순' }, - { value: 'PAY_AMOUNT', label: '급여순' }, -] as const +import { SortSalaryFilterSections } from '@/features/job-lookup-map/common/SortSalaryFilterSections' +import { useSortSalaryFilterDraft } from '@/features/job-lookup-map/hooks/useSortSalaryFilterDraft' +import type { SalaryFilterSelection } from '@/features/job-lookup-map/lib/postingFilters' type NearbyModeFilterValues = { sortValue: string @@ -29,30 +18,6 @@ type NearbyModeFilterDrawerProps = { onApply: (values: NearbyModeFilterValues) => void } -function FilterChip({ - selected, - label, - onClick, -}: { - selected: boolean - label: string - onClick: () => void -}) { - return ( - - ) -} - function NearbyModeFilterDrawerBody({ value, onOpenChange, @@ -62,167 +27,46 @@ function NearbyModeFilterDrawerBody({ onOpenChange: (open: boolean) => void onApply: (values: NearbyModeFilterValues) => void }) { - const [sortDraft, setSortDraft] = useState(value.sortValue) - const [salaryDraft, setSalaryDraft] = useState(value.salaryFilter) - const [minInput, setMinInput] = useState( - formatSalaryInput(value.salaryFilter.min) - ) - const [maxInput, setMaxInput] = useState( - formatSalaryInput(value.salaryFilter.max) - ) - - const handleSalaryPreset = (preset: 'all' | 'custom') => { - if (preset === 'all') { - setSalaryDraft(EMPTY_SALARY_FILTER) - setMinInput('') - setMaxInput('') - return - } - - setSalaryDraft(prev => ({ - preset: 'custom', - min: prev.min, - max: prev.max, - })) - } - - const handleMinChange = (raw: string) => { - const min = parseSalaryInput(raw) - setMinInput(min != null ? formatSalaryInput(min) : '') - setSalaryDraft(prev => ({ - preset: 'custom', - min, - max: prev.max, - })) - } - - const handleMaxChange = (raw: string) => { - const max = parseSalaryInput(raw) - setMaxInput(max != null ? formatSalaryInput(max) : '') - setSalaryDraft(prev => ({ - preset: 'custom', - min: prev.min, - max, - })) - } - - const handleReset = () => { - setSortDraft(DEFAULT_SORT_VALUE) - setSalaryDraft(EMPTY_SALARY_FILTER) - setMinInput('') - setMaxInput('') - } + const { + sortDraft, + setSortDraft, + salaryDraft, + minInput, + maxInput, + handleSalaryPreset, + handleMinChange, + handleMaxChange, + resetSortSalary, + getNormalizedSalary, + } = useSortSalaryFilterDraft(value) const handleApply = () => { - let nextSalary = salaryDraft - if ( - salaryDraft.min != null && - salaryDraft.max != null && - salaryDraft.min > salaryDraft.max - ) { - nextSalary = { - ...salaryDraft, - min: salaryDraft.max, - max: salaryDraft.min, - } - } - onApply({ sortValue: sortDraft, - salaryFilter: nextSalary, + salaryFilter: getNormalizedSalary(), }) onOpenChange(false) } - const isCustomSalary = salaryDraft.preset === 'custom' - return ( <>
-
-

정렬

-
- {SORT_OPTIONS.map(option => ( - setSortDraft(option.value)} - /> - ))} -
-
- -
-

급여

-
- handleSalaryPreset('all')} - /> - handleSalaryPreset('custom')} - /> -
- - {isCustomSalary ? ( -
-

- 최소 / 최대 시급 -

-
- - - ~ - - -
-
- ) : null} -
+
diff --git a/src/features/job-lookup-map/common/RegionModeFilterDrawer.tsx b/src/features/job-lookup-map/common/RegionModeFilterDrawer.tsx index e3d255c6..e4eec030 100644 --- a/src/features/job-lookup-map/common/RegionModeFilterDrawer.tsx +++ b/src/features/job-lookup-map/common/RegionModeFilterDrawer.tsx @@ -1,18 +1,15 @@ import { useMemo, useState } from 'react' import { ChevronRightIcon } from '@/assets/icons/ChevronRightIcon' +import { FilterChip } from '@/features/job-lookup-map/common/FilterChip' import { FilterDrawerApplyFooter, FilterDrawerShell, } from '@/features/job-lookup-map/common/FilterDrawerShell' +import { SortSalaryFilterSections } from '@/features/job-lookup-map/common/SortSalaryFilterSections' import { useAddresses } from '@/features/job-lookup-map/hooks/useAddresses' -import { - DEFAULT_SORT_VALUE, - EMPTY_SALARY_FILTER, - formatSalaryInput, - parseSalaryInput, - type SalaryFilterSelection, -} from '@/features/job-lookup-map/lib/postingFilters' +import { useSortSalaryFilterDraft } from '@/features/job-lookup-map/hooks/useSortSalaryFilterDraft' +import type { SalaryFilterSelection } from '@/features/job-lookup-map/lib/postingFilters' import { EMPTY_REGION_SELECTION, REGION_STEPS, @@ -22,11 +19,6 @@ import { type RegionStep, } from '@/features/job-lookup-map/lib/regionOptions' -const SORT_OPTIONS = [ - { value: 'LATEST', label: '최신순' }, - { value: 'PAY_AMOUNT', label: '급여순' }, -] as const - type RegionModeFilterValues = { regionSelection: RegionSelection sortValue: string @@ -40,30 +32,6 @@ type RegionModeFilterDrawerProps = { onApply: (values: RegionModeFilterValues) => void } -function FilterChip({ - selected, - label, - onClick, -}: { - selected: boolean - label: string - onClick: () => void -}) { - return ( - - ) -} - function getStepLabel(step: RegionStep, draft: RegionSelection): string { if (step === 'sido') { return draft.sido && draft.sido !== '전국(전체)' ? draft.sido : '시/도' @@ -88,15 +56,19 @@ function RegionModeFilterDrawerBody({ const [regionDraft, setRegionDraft] = useState( value.regionSelection ) - const [sortDraft, setSortDraft] = useState(value.sortValue) - const [salaryDraft, setSalaryDraft] = useState(value.salaryFilter) - const [minInput, setMinInput] = useState( - formatSalaryInput(value.salaryFilter.min) - ) - const [maxInput, setMaxInput] = useState( - formatSalaryInput(value.salaryFilter.max) - ) const [regionStep, setRegionStep] = useState('sido') + const { + sortDraft, + setSortDraft, + salaryDraft, + minInput, + maxInput, + handleSalaryPreset, + handleMinChange, + handleMaxChange, + resetSortSalary, + getNormalizedSalary, + } = useSortSalaryFilterDraft(value) const parentCode = regionStep === 'sigungu' @@ -203,74 +175,21 @@ function RegionModeFilterDrawerBody({ }) } - const handleSalaryPreset = (preset: 'all' | 'custom') => { - if (preset === 'all') { - setSalaryDraft(EMPTY_SALARY_FILTER) - setMinInput('') - setMaxInput('') - return - } - - setSalaryDraft(prev => ({ - preset: 'custom', - min: prev.min, - max: prev.max, - })) - } - - const handleMinChange = (raw: string) => { - const min = parseSalaryInput(raw) - setMinInput(min != null ? formatSalaryInput(min) : '') - setSalaryDraft(prev => ({ - preset: 'custom', - min, - max: prev.max, - })) - } - - const handleMaxChange = (raw: string) => { - const max = parseSalaryInput(raw) - setMaxInput(max != null ? formatSalaryInput(max) : '') - setSalaryDraft(prev => ({ - preset: 'custom', - min: prev.min, - max, - })) - } - const handleReset = () => { setRegionDraft(EMPTY_REGION_SELECTION) setRegionStep('sido') - setSortDraft(DEFAULT_SORT_VALUE) - setSalaryDraft(EMPTY_SALARY_FILTER) - setMinInput('') - setMaxInput('') + resetSortSalary() } const handleApply = () => { - let nextSalary = salaryDraft - if ( - salaryDraft.min != null && - salaryDraft.max != null && - salaryDraft.min > salaryDraft.max - ) { - nextSalary = { - ...salaryDraft, - min: salaryDraft.max, - max: salaryDraft.min, - } - } - onApply({ regionSelection: regionDraft, sortValue: sortDraft, - salaryFilter: nextSalary, + salaryFilter: getNormalizedSalary(), }) onOpenChange(false) } - const isCustomSalary = salaryDraft.preset === 'custom' - return ( <>
@@ -331,84 +250,17 @@ function RegionModeFilterDrawerBody({
-
-

정렬

-
- {SORT_OPTIONS.map(option => ( - setSortDraft(option.value)} - /> - ))} -
-
- -
-

급여

-
- handleSalaryPreset('all')} - /> - handleSalaryPreset('custom')} - /> -
- - {isCustomSalary ? ( -
-

- 최소 / 최대 시급 -

-
- - - ~ - - -
-
- ) : null} -
+
void - value?: RegionSelection - onApply: (selection: RegionSelection) => void -} - -function CloseIcon({ className }: { className?: string }) { - return ( - - - - ) -} - -function CheckIcon({ className }: { className?: string }) { - return ( - - - - ) -} - -function RegionSelectDrawerBody({ - value, - onOpenChange, - onApply, -}: { - value: RegionSelection - onOpenChange: (open: boolean) => void - onApply: (selection: RegionSelection) => void -}) { - const [draft, setDraft] = useState(value) - const [step, setStep] = useState('sido') - - const parentCode = - step === 'sigungu' - ? (draft.sidoCode ?? undefined) - : step === 'dong' - ? (draft.sigunguCode ?? undefined) - : undefined - - const addressesEnabled = - step === 'sido' || - (step === 'sigungu' && draft.sidoCode != null) || - (step === 'dong' && draft.sigunguCode != null) - - const { addresses, isLoading, isError } = useAddresses( - parentCode, - addressesEnabled - ) - - const options = useMemo( - () => getRegionOptionsForStep(step, addresses), - [step, addresses] - ) - - const handleSelect = (option: RegionOption) => { - if (step === 'sido') { - if (option.name === '전국(전체)') { - setDraft(EMPTY_REGION_SELECTION) - return - } - - const next: RegionSelection = { - sido: option.name, - sidoCode: option.code, - sigungu: null, - sigunguCode: null, - dong: null, - dongCode: null, - } - setDraft(next) - setStep('sigungu') - return - } - - if (step === 'sigungu') { - if (option.name === '전체') { - setDraft({ - ...draft, - sigungu: '전체', - sigunguCode: null, - dong: '전체', - dongCode: null, - }) - return - } - - const next: RegionSelection = { - ...draft, - sigungu: option.name, - sigunguCode: option.code, - dong: null, - dongCode: null, - } - setDraft(next) - setStep('dong') - return - } - - if (option.name === '전체') { - setDraft({ - ...draft, - dong: '전체', - dongCode: null, - }) - return - } - - setDraft({ - ...draft, - dong: option.name, - dongCode: option.code, - }) - } - - const handleReset = () => { - setDraft(EMPTY_REGION_SELECTION) - setStep('sido') - } - - const handleApply = () => { - onApply(draft) - onOpenChange(false) - } - - const canGoToStep = (target: RegionStep) => { - if (target === 'sido') return true - if (target === 'sigungu') { - return draft.sido != null && draft.sido !== '전국(전체)' - } - return ( - draft.sido != null && - draft.sido !== '전국(전체)' && - draft.sigungu != null && - draft.sigungu !== '전체' - ) - } - - const selectedForStep = - step === 'sido' - ? draft.sido - : step === 'sigungu' - ? draft.sigungu - : draft.dong - - const canApply = isRegionSelectionComplete(draft) - - return ( -
-
- -
- - 지역 선택 - - -
- -
- {REGION_STEPS.map((item, index) => { - const active = step === item.key - const enabled = canGoToStep(item.key) - return ( -
- {index > 0 ? ( - - ) : null} - -
- ) - })} -
- -
- {isLoading ? ( -

- 지역 정보를 불러오는 중… -

- ) : isError ? ( -

- 지역 정보를 불러오지 못했습니다. -

- ) : options.length === 0 ? ( -

- 선택 가능한 지역이 없습니다. -

- ) : ( -
    - {options.map(option => { - const selected = selectedForStep === option.name - return ( -
  • - -
  • - ) - })} -
- )} -
- -
- - -
-
- ) -} - -export function RegionSelectDrawer({ - open, - onOpenChange, - value = EMPTY_REGION_SELECTION, - onApply, -}: RegionSelectDrawerProps) { - return ( - - - - -
- {open ? ( - - ) : null} -
-
-
-
- ) -} diff --git a/src/features/job-lookup-map/common/SalarySelectDrawer.tsx b/src/features/job-lookup-map/common/SalarySelectDrawer.tsx deleted file mode 100644 index f4aeca71..00000000 --- a/src/features/job-lookup-map/common/SalarySelectDrawer.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { useState } from 'react' - -import { - FilterDrawerApplyFooter, - FilterDrawerShell, -} from '@/features/job-lookup-map/common/FilterDrawerShell' -import { - EMPTY_SALARY_FILTER, - SALARY_PRESETS, - formatSalaryInput, - parseSalaryInput, - type SalaryFilterSelection, - type SalaryPreset, -} from '@/features/job-lookup-map/lib/postingFilters' - -type SalarySelectDrawerProps = { - open: boolean - onOpenChange: (open: boolean) => void - value?: SalaryFilterSelection - onApply: (selection: SalaryFilterSelection) => void -} - -function SalarySelectDrawerBody({ - value, - onOpenChange, - onApply, -}: { - value: SalaryFilterSelection - onOpenChange: (open: boolean) => void - onApply: (selection: SalaryFilterSelection) => void -}) { - const [draft, setDraft] = useState(value) - const [minInput, setMinInput] = useState(formatSalaryInput(value.min)) - const [maxInput, setMaxInput] = useState(formatSalaryInput(value.max)) - - const handlePresetSelect = (preset: SalaryPreset, min: number | null) => { - setDraft({ preset, min, max: null }) - setMinInput(formatSalaryInput(min)) - setMaxInput('') - } - - const handleMinChange = (raw: string) => { - const min = parseSalaryInput(raw) - setMinInput(min != null ? formatSalaryInput(min) : '') - setDraft(prev => ({ - preset: 'custom', - min, - max: prev.max, - })) - } - - const handleMaxChange = (raw: string) => { - const max = parseSalaryInput(raw) - setMaxInput(max != null ? formatSalaryInput(max) : '') - setDraft(prev => ({ - preset: 'custom', - min: prev.min, - max, - })) - } - - const handleReset = () => { - setDraft(EMPTY_SALARY_FILTER) - setMinInput('') - setMaxInput('') - } - - const handleApply = () => { - if (draft.min != null && draft.max != null && draft.min > draft.max) { - onApply({ ...draft, min: draft.max, max: draft.min }) - onOpenChange(false) - return - } - onApply(draft) - onOpenChange(false) - } - - return ( - <> -
-
- {SALARY_PRESETS.map(({ preset, label, min }) => { - const selected = draft.preset === preset - return ( - - ) - })} -
- -

- 직접 입력 (min / max) -

- -
- - ~ - -
-
- - - - ) -} - -export function SalarySelectDrawer({ - open, - onOpenChange, - value = EMPTY_SALARY_FILTER, - onApply, -}: SalarySelectDrawerProps) { - return ( - - {open ? ( - - ) : null} - - ) -} diff --git a/src/features/job-lookup-map/common/SortSalaryFilterSections.tsx b/src/features/job-lookup-map/common/SortSalaryFilterSections.tsx new file mode 100644 index 00000000..a9011d4e --- /dev/null +++ b/src/features/job-lookup-map/common/SortSalaryFilterSections.tsx @@ -0,0 +1,110 @@ +import { FilterChip } from '@/features/job-lookup-map/common/FilterChip' +import { SORT_OPTIONS } from '@/features/job-lookup-map/lib/postingFilters' +import type { SalaryFilterSelection } from '@/features/job-lookup-map/lib/postingFilters' + +type SortSalaryFilterSectionsProps = { + sortDraft: string + onSortChange: (value: string) => void + salaryDraft: SalaryFilterSelection + minInput: string + maxInput: string + onSalaryPreset: (preset: 'all' | 'custom') => void + onMinChange: (raw: string) => void + onMaxChange: (raw: string) => void + className?: string +} + +export function SortSalaryFilterSections({ + sortDraft, + onSortChange, + salaryDraft, + minInput, + maxInput, + onSalaryPreset, + onMinChange, + onMaxChange, + className, +}: SortSalaryFilterSectionsProps) { + const isCustomSalary = salaryDraft.preset === 'custom' + + return ( + <> +
+

정렬

+
+ {SORT_OPTIONS.map(option => ( + onSortChange(option.value)} + /> + ))} +
+
+ +
+

급여

+
+ onSalaryPreset('all')} + /> + onSalaryPreset('custom')} + /> +
+ + {isCustomSalary ? ( +
+

+ 최소 / 최대 시급 +

+
+ + ~ + +
+
+ ) : null} +
+ + ) +} diff --git a/src/features/job-lookup-map/common/SortSelectDrawer.tsx b/src/features/job-lookup-map/common/SortSelectDrawer.tsx deleted file mode 100644 index aa0cfcf2..00000000 --- a/src/features/job-lookup-map/common/SortSelectDrawer.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useState } from 'react' - -import { - FilterDrawerApplyFooter, - FilterDrawerShell, - RadioIndicator, -} from '@/features/job-lookup-map/common/FilterDrawerShell' -import { - DEFAULT_SORT_VALUE, - formatSortOptionLabel, -} from '@/features/job-lookup-map/lib/postingFilters' - -const SORT_OPTIONS = [ - { value: 'LATEST', description: '최신순' }, - { value: 'PAY_AMOUNT', description: '급여순' }, -] as const - -type SortSelectDrawerProps = { - open: boolean - onOpenChange: (open: boolean) => void - value?: string - onApply: (value: string) => void -} - -function SortSelectDrawerBody({ - value, - onOpenChange, - onApply, -}: { - value: string - onOpenChange: (open: boolean) => void - onApply: (value: string) => void -}) { - const [draft, setDraft] = useState(value) - - const handleApply = () => { - onApply(draft) - onOpenChange(false) - } - - return ( - <> -
-
    - {SORT_OPTIONS.map(option => { - const selected = draft === option.value - return ( -
  • - -
  • - ) - })} -
-
- - - - ) -} - -export function SortSelectDrawer({ - open, - onOpenChange, - value = DEFAULT_SORT_VALUE, - onApply, -}: SortSelectDrawerProps) { - return ( - - {open ? ( - - ) : null} - - ) -} diff --git a/src/features/job-lookup-map/hooks/useSortSalaryFilterDraft.ts b/src/features/job-lookup-map/hooks/useSortSalaryFilterDraft.ts new file mode 100644 index 00000000..ccde0835 --- /dev/null +++ b/src/features/job-lookup-map/hooks/useSortSalaryFilterDraft.ts @@ -0,0 +1,96 @@ +import { useState } from 'react' + +import { + DEFAULT_SORT_VALUE, + EMPTY_SALARY_FILTER, + formatSalaryInput, + parseSalaryInput, + type SalaryFilterSelection, +} from '@/features/job-lookup-map/lib/postingFilters' + +type SortSalaryFilterDraft = { + sortValue: string + salaryFilter: SalaryFilterSelection +} + +export function useSortSalaryFilterDraft(initial: SortSalaryFilterDraft) { + const [sortDraft, setSortDraft] = useState(initial.sortValue) + const [salaryDraft, setSalaryDraft] = useState(initial.salaryFilter) + const [minInput, setMinInput] = useState( + formatSalaryInput(initial.salaryFilter.min) + ) + const [maxInput, setMaxInput] = useState( + formatSalaryInput(initial.salaryFilter.max) + ) + + const handleSalaryPreset = (preset: 'all' | 'custom') => { + if (preset === 'all') { + setSalaryDraft(EMPTY_SALARY_FILTER) + setMinInput('') + setMaxInput('') + return + } + + setSalaryDraft(prev => ({ + preset: 'custom', + min: prev.min, + max: prev.max, + })) + } + + const handleMinChange = (raw: string) => { + const min = parseSalaryInput(raw) + setMinInput(min != null ? formatSalaryInput(min) : '') + setSalaryDraft(prev => ({ + preset: 'custom', + min, + max: prev.max, + })) + } + + const handleMaxChange = (raw: string) => { + const max = parseSalaryInput(raw) + setMaxInput(max != null ? formatSalaryInput(max) : '') + setSalaryDraft(prev => ({ + preset: 'custom', + min: prev.min, + max, + })) + } + + const resetSortSalary = () => { + setSortDraft(DEFAULT_SORT_VALUE) + setSalaryDraft(EMPTY_SALARY_FILTER) + setMinInput('') + setMaxInput('') + } + + const getNormalizedSalary = (): SalaryFilterSelection => { + if ( + salaryDraft.min != null && + salaryDraft.max != null && + salaryDraft.min > salaryDraft.max + ) { + return { + ...salaryDraft, + min: salaryDraft.max, + max: salaryDraft.min, + } + } + + return salaryDraft + } + + return { + sortDraft, + setSortDraft, + salaryDraft, + minInput, + maxInput, + handleSalaryPreset, + handleMinChange, + handleMaxChange, + resetSortSalary, + getNormalizedSalary, + } +} diff --git a/src/features/job-lookup-map/lib/postingFilters.ts b/src/features/job-lookup-map/lib/postingFilters.ts index f5f2174b..2534a763 100644 --- a/src/features/job-lookup-map/lib/postingFilters.ts +++ b/src/features/job-lookup-map/lib/postingFilters.ts @@ -6,7 +6,12 @@ import { export type AlbaFindMode = 'nearby' | 'region' -export type SalaryPreset = 'all' | '12000' | '15000' | 'custom' +export type SalaryPreset = 'all' | 'custom' + +export const SORT_OPTIONS = [ + { value: 'LATEST', label: '최신순', description: '최신순' }, + { value: 'PAY_AMOUNT', label: '급여순', description: '급여순' }, +] as const export type SalaryFilterSelection = { preset: SalaryPreset @@ -31,21 +36,9 @@ export function formatSortOptionLabel(option: { return option.description } -export const SALARY_PRESETS: { - preset: SalaryPreset - label: string - min: number | null -}[] = [ - { preset: 'all', label: '전체', min: null }, - { preset: '12000', label: '1.2만원 이상', min: 12000 }, - { preset: '15000', label: '1.5만원 이상', min: 15000 }, -] - export function formatSalaryChipLabel( selection: SalaryFilterSelection ): string { - if (selection.preset === '12000') return '1.2만원 이상' - if (selection.preset === '15000') return '1.5만원 이상' if (selection.preset === 'custom') { if (selection.min != null && selection.max != null) { return `${selection.min.toLocaleString('ko-KR')}~${selection.max.toLocaleString('ko-KR')}원` @@ -68,11 +61,8 @@ export function isSalaryFilterApplied( ) } -export function formatSortChipLabel( - value: string, - options: { value: string; description: string }[] -): string { - const option = options.find(item => item.value === value) +export function formatSortChipLabel(value: string): string { + const option = SORT_OPTIONS.find(item => item.value === value) if (option) return formatSortOptionLabel(option) return '최신순' }