From d6393157792d5593d0949b179ee3bf9c98b6e9f3 Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Sun, 26 Jul 2026 20:30:32 +0900
Subject: [PATCH 01/33] =?UTF-8?q?feat:=20Toast=C2=B7SelectDropdown=C2=B7Sk?=
=?UTF-8?q?eleton=20=EA=B3=B5=EC=9A=A9=20=EC=BB=B4=ED=8F=AC=EB=84=8C?=
=?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/shared/stores/useToastStore.ts | 44 ++++++++
src/shared/ui/common/SelectDropdown.tsx | 131 ++++++++++++++++++++++++
src/shared/ui/common/Skeleton.tsx | 17 +++
src/shared/ui/common/Toast.tsx | 71 +++++++++++++
src/shared/ui/common/ToastViewport.tsx | 25 +++++
tailwind.config.js | 10 ++
6 files changed, 298 insertions(+)
create mode 100644 src/shared/stores/useToastStore.ts
create mode 100644 src/shared/ui/common/SelectDropdown.tsx
create mode 100644 src/shared/ui/common/Skeleton.tsx
create mode 100644 src/shared/ui/common/Toast.tsx
create mode 100644 src/shared/ui/common/ToastViewport.tsx
diff --git a/src/shared/stores/useToastStore.ts b/src/shared/stores/useToastStore.ts
new file mode 100644
index 00000000..1d5460c8
--- /dev/null
+++ b/src/shared/stores/useToastStore.ts
@@ -0,0 +1,44 @@
+import { create } from 'zustand'
+
+export type ToastVariant = 'success' | 'error'
+
+export interface ToastItem {
+ id: number
+ message: string
+ variant: ToastVariant
+}
+
+/** 자동 dismiss 지연(ms) */
+export const TOAST_DURATION = 2000
+
+interface ToastState {
+ toasts: ToastItem[]
+ showToast: (message: string, variant?: ToastVariant) => void
+ dismissToast: (id: number) => void
+}
+
+let toastSeq = 0
+
+export const useToastStore = create(set => ({
+ toasts: [],
+ showToast: (message, variant = 'success') => {
+ toastSeq += 1
+ const id = toastSeq
+ set(state => ({ toasts: [...state.toasts, { id, message, variant }] }))
+ setTimeout(() => {
+ set(state => ({ toasts: state.toasts.filter(t => t.id !== id) }))
+ }, TOAST_DURATION)
+ },
+ dismissToast: id =>
+ set(state => ({ toasts: state.toasts.filter(t => t.id !== id) })),
+}))
+
+/**
+ * 컴포넌트 외부(핸들러·훅)에서도 호출 가능한 단축 함수
+ * 예) showToast('최종합격 처리됐어요')
+ */
+export function showToast(message: string, variant: ToastVariant = 'success') {
+ useToastStore.getState().showToast(message, variant)
+}
+
+export default useToastStore
diff --git a/src/shared/ui/common/SelectDropdown.tsx b/src/shared/ui/common/SelectDropdown.tsx
new file mode 100644
index 00000000..89832234
--- /dev/null
+++ b/src/shared/ui/common/SelectDropdown.tsx
@@ -0,0 +1,131 @@
+import { useEffect, useId, useRef, useState } from 'react'
+
+import DownIcon from '@/assets/icons/home/chevron-down.svg?react'
+import { cn } from '@/shared/lib/utils'
+
+export interface SelectOption {
+ value: T
+ label: string
+}
+
+interface SelectDropdownProps {
+ options: SelectOption[]
+ value: T
+ onChange: (value: T) => void
+ /** 접근성 라벨 (예: '업장 필터') */
+ ariaLabel: string
+ placeholder?: string
+ disabled?: boolean
+ className?: string
+}
+
+/**
+ * 박스형 Select — FilterBar(업장·상태), 공고 등록 폼(업장 선택) 등에 사용.
+ * 외부 클릭·Escape로 닫히며, 옵션은 문자열/숫자 값을 지원합니다.
+ */
+export function SelectDropdown({
+ options,
+ value,
+ onChange,
+ ariaLabel,
+ placeholder = '선택',
+ disabled = false,
+ className,
+}: SelectDropdownProps) {
+ const [isOpen, setIsOpen] = useState(false)
+ const containerRef = useRef(null)
+ const listId = useId()
+
+ useEffect(() => {
+ if (!isOpen) return
+
+ function handleOutsideClick(e: MouseEvent) {
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(e.target as Node)
+ ) {
+ setIsOpen(false)
+ }
+ }
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Escape') setIsOpen(false)
+ }
+
+ document.addEventListener('mousedown', handleOutsideClick)
+ document.addEventListener('keydown', handleKeyDown)
+ return () => {
+ document.removeEventListener('mousedown', handleOutsideClick)
+ document.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [isOpen])
+
+ const selected = options.find(option => option.value === value)
+
+ return (
+
+
+
+ {isOpen ? (
+
+ {options.map(option => {
+ const isSelected = option.value === value
+ return (
+ -
+
+
+ )
+ })}
+
+ ) : null}
+
+ )
+}
diff --git a/src/shared/ui/common/Skeleton.tsx b/src/shared/ui/common/Skeleton.tsx
new file mode 100644
index 00000000..a5f5b73f
--- /dev/null
+++ b/src/shared/ui/common/Skeleton.tsx
@@ -0,0 +1,17 @@
+import { cn } from '@/shared/lib/utils'
+
+interface SkeletonProps {
+ className?: string
+}
+
+/** 로딩 플레이스홀더 블록 */
+export function Skeleton({ className }: SkeletonProps) {
+ return (
+
+ )
+}
+
+export type { SkeletonProps }
diff --git a/src/shared/ui/common/Toast.tsx b/src/shared/ui/common/Toast.tsx
new file mode 100644
index 00000000..7ae50270
--- /dev/null
+++ b/src/shared/ui/common/Toast.tsx
@@ -0,0 +1,71 @@
+import { cn } from '@/shared/lib/utils'
+import type { ToastVariant } from '@/shared/stores/useToastStore'
+
+interface ToastProps {
+ message: string
+ variant?: ToastVariant
+ className?: string
+}
+
+function CheckCircleIcon({ className }: { className?: string }) {
+ return (
+
+ )
+}
+
+function AlertCircleIcon({ className }: { className?: string }) {
+ return (
+
+ )
+}
+
+/** 하단 다크 pill 형태의 스낵바 — ToastViewport를 통해 렌더링됩니다 */
+export function Toast({ message, variant = 'success', className }: ToastProps) {
+ const Icon = variant === 'success' ? CheckCircleIcon : AlertCircleIcon
+
+ return (
+
+
+ {message}
+
+ )
+}
+
+export type { ToastProps }
diff --git a/src/shared/ui/common/ToastViewport.tsx b/src/shared/ui/common/ToastViewport.tsx
new file mode 100644
index 00000000..5994b5d8
--- /dev/null
+++ b/src/shared/ui/common/ToastViewport.tsx
@@ -0,0 +1,25 @@
+import { Toast } from '@/shared/ui/common/Toast'
+import { useToastStore } from '@/shared/stores/useToastStore'
+
+/**
+ * 앱 루트에 1회 마운트하는 Toast 렌더링 영역.
+ * Docbar(h-14) 위쪽에 겹치지 않도록 bottom 여백을 둡니다.
+ */
+export function ToastViewport() {
+ const toasts = useToastStore(state => state.toasts)
+
+ if (toasts.length === 0) return null
+
+ return (
+
+ {toasts.map(toast => (
+
+ ))}
+
+ )
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index c0d6bc01..35547934 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -118,6 +118,16 @@ export default {
regular: '400',
semibold: '600',
},
+ // Animations
+ keyframes: {
+ 'toast-in': {
+ '0%': { opacity: '0', transform: 'translateY(8px)' },
+ '100%': { opacity: '1', transform: 'translateY(0)' },
+ },
+ },
+ animation: {
+ 'toast-in': 'toast-in 180ms ease-out',
+ },
},
},
plugins: [],
From 117eff2e28205f20404b5fa388fccad5f814b1b6 Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Sun, 26 Jul 2026 20:30:34 +0900
Subject: [PATCH 02/33] =?UTF-8?q?feat:=20=EC=82=AC=EC=9E=A5=EB=8B=98=20?=
=?UTF-8?q?=EA=B5=AC=EC=9D=B8=EA=B5=AC=EC=A7=81=20=EB=9D=BC=EC=9A=B0?=
=?UTF-8?q?=ED=8A=B8=20=EC=83=81=EC=88=98=C2=B7path=20=ED=97=AC=ED=8D=BC?=
=?UTF-8?q?=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/shared/constants/routes.ts | 31 +++++++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts
index ee395b46..115edc79 100644
--- a/src/shared/constants/routes.ts
+++ b/src/shared/constants/routes.ts
@@ -37,6 +37,19 @@ export const ROUTES = {
WORKER_INVITE: '/manager/worker-invite',
SOCIAL: '/manager/social',
SOCIAL_CHAT: '/manager/social/chat',
+ /** 구인구직 — 내 공고 목록 (사장님 알바찾기 탭 진입점) */
+ POSTINGS: '/manager/postings',
+ /** 공고 등록 */
+ POSTING_NEW: '/manager/postings/new',
+ /** 지원자 목록 */
+ POSTING_APPLICATIONS: '/manager/postings/applications',
+ /** 지원자 상세·채용 결정 (파라미터) */
+ POSTING_APPLICATION_DETAIL_PATTERN:
+ '/manager/postings/applications/:applicationId',
+ /** 공고 상세·마감 (파라미터) */
+ POSTING_DETAIL_PATTERN: '/manager/postings/:postingId',
+ /** 공고 수정 (파라미터) */
+ POSTING_EDIT_PATTERN: '/manager/postings/:postingId/edit',
},
MY: {
ROOT: '/my',
@@ -72,3 +85,21 @@ export function managerWorkerSchedulePath(
export function managerWorkspaceImagesEditPath(workspaceId: number) {
return `/manager/workspaces/${workspaceId}/images/edit`
}
+
+export function managerPostingDetailPath(postingId: number) {
+ return `/manager/postings/${postingId}`
+}
+
+export function managerPostingEditPath(postingId: number) {
+ return `/manager/postings/${postingId}/edit`
+}
+
+export function managerPostingApplicationsPath(postingId?: number) {
+ return postingId === undefined
+ ? '/manager/postings/applications'
+ : `/manager/postings/applications?postingId=${postingId}`
+}
+
+export function managerPostingApplicationDetailPath(applicationId: number) {
+ return `/manager/postings/applications/${applicationId}`
+}
From 64b1b0740fbfde182d96ebfecbd12f496c99a4cd Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Sun, 26 Jul 2026 20:30:35 +0900
Subject: [PATCH 03/33] =?UTF-8?q?feat:=20=EC=82=AC=EC=9E=A5=EB=8B=98=20?=
=?UTF-8?q?=EA=B5=AC=EC=9D=B8=EA=B5=AC=EC=A7=81=20feature=20=EC=8A=AC?=
=?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=8A=A4=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../hooks/useApplicationDetailViewModel.ts | 49 ++++
.../hooks/useApplicationListViewModel.ts | 102 +++++++
.../hooks/usePostingDetailViewModel.ts | 40 +++
.../manager/posting/hooks/usePostingForm.ts | 221 +++++++++++++++
.../posting/hooks/usePostingListViewModel.ts | 67 +++++
.../manager/posting/lib/applicationStatus.ts | 117 ++++++++
.../manager/posting/lib/postingStatus.ts | 37 +++
src/features/manager/posting/mocks/data.ts | 264 ++++++++++++++++++
src/features/manager/posting/mocks/store.ts | 121 ++++++++
src/features/manager/posting/types/posting.ts | 178 ++++++++++++
.../manager/posting/ui/ApplicantCard.tsx | 62 ++++
src/features/manager/posting/ui/FilterBar.tsx | 52 ++++
.../manager/posting/ui/HiringActionBar.tsx | 51 ++++
.../ui/ManagerApplicationStatusBadge.tsx | 29 ++
.../posting/ui/ManagerPostingStatusBadge.tsx | 29 ++
.../manager/posting/ui/PostingFormFields.tsx | 197 +++++++++++++
.../manager/posting/ui/PostingListCard.tsx | 71 +++++
.../manager/posting/ui/ScheduleEditor.tsx | 177 ++++++++++++
src/features/manager/posting/ui/icons.tsx | 164 +++++++++++
19 files changed, 2028 insertions(+)
create mode 100644 src/features/manager/posting/hooks/useApplicationDetailViewModel.ts
create mode 100644 src/features/manager/posting/hooks/useApplicationListViewModel.ts
create mode 100644 src/features/manager/posting/hooks/usePostingDetailViewModel.ts
create mode 100644 src/features/manager/posting/hooks/usePostingForm.ts
create mode 100644 src/features/manager/posting/hooks/usePostingListViewModel.ts
create mode 100644 src/features/manager/posting/lib/applicationStatus.ts
create mode 100644 src/features/manager/posting/lib/postingStatus.ts
create mode 100644 src/features/manager/posting/mocks/data.ts
create mode 100644 src/features/manager/posting/mocks/store.ts
create mode 100644 src/features/manager/posting/types/posting.ts
create mode 100644 src/features/manager/posting/ui/ApplicantCard.tsx
create mode 100644 src/features/manager/posting/ui/FilterBar.tsx
create mode 100644 src/features/manager/posting/ui/HiringActionBar.tsx
create mode 100644 src/features/manager/posting/ui/ManagerApplicationStatusBadge.tsx
create mode 100644 src/features/manager/posting/ui/ManagerPostingStatusBadge.tsx
create mode 100644 src/features/manager/posting/ui/PostingFormFields.tsx
create mode 100644 src/features/manager/posting/ui/PostingListCard.tsx
create mode 100644 src/features/manager/posting/ui/ScheduleEditor.tsx
create mode 100644 src/features/manager/posting/ui/icons.tsx
diff --git a/src/features/manager/posting/hooks/useApplicationDetailViewModel.ts b/src/features/manager/posting/hooks/useApplicationDetailViewModel.ts
new file mode 100644
index 00000000..cd08c19d
--- /dev/null
+++ b/src/features/manager/posting/hooks/useApplicationDetailViewModel.ts
@@ -0,0 +1,49 @@
+import { useMemo, useState } from 'react'
+
+import {
+ DECISION_COPY,
+ isTerminalApplicationStatus,
+ type HiringDecision,
+} from '@/features/manager/posting/lib/applicationStatus'
+import { useMockPostingStore } from '@/features/manager/posting/mocks/store'
+import { showToast } from '@/shared/stores/useToastStore'
+
+/** 지원자 상세 — 인적사항 조회 + 채용 결정(서류합격·최종합격·불합격) */
+export function useApplicationDetailViewModel(applicationId: number) {
+ const applications = useMockPostingStore(state => state.applications)
+ const updateApplicationStatus = useMockPostingStore(
+ state => state.updateApplicationStatus
+ )
+
+ const [pendingDecision, setPendingDecision] = useState(
+ null
+ )
+
+ const application = useMemo(
+ () => applications.find(item => item.id === applicationId) ?? null,
+ [applications, applicationId]
+ )
+
+ const isTerminal = application
+ ? isTerminalApplicationStatus(application.status)
+ : false
+
+ const confirmDecision = () => {
+ if (!pendingDecision) return
+ updateApplicationStatus(applicationId, pendingDecision)
+ showToast(DECISION_COPY[pendingDecision].toast)
+ setPendingDecision(null)
+ }
+
+ return {
+ application,
+ isNotFound: application === null,
+ /** 종료된 지원서는 채용 결정 액션을 숨깁니다 */
+ canDecide: application !== null && !isTerminal,
+ pendingDecision,
+ decisionCopy: pendingDecision ? DECISION_COPY[pendingDecision] : null,
+ requestDecision: (decision: HiringDecision) => setPendingDecision(decision),
+ cancelDecision: () => setPendingDecision(null),
+ confirmDecision,
+ }
+}
diff --git a/src/features/manager/posting/hooks/useApplicationListViewModel.ts b/src/features/manager/posting/hooks/useApplicationListViewModel.ts
new file mode 100644
index 00000000..6aab2d49
--- /dev/null
+++ b/src/features/manager/posting/hooks/useApplicationListViewModel.ts
@@ -0,0 +1,102 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+
+import type { ApplicationStatusFilter } from '@/features/manager/posting/lib/applicationStatus'
+import { MOCK_WORKSPACES } from '@/features/manager/posting/mocks/data'
+import {
+ MOCK_LATENCY,
+ useMockPostingStore,
+} from '@/features/manager/posting/mocks/store'
+import {
+ ALL_WORKSPACES,
+ type WorkspaceFilter,
+} from '@/features/manager/posting/hooks/usePostingListViewModel'
+
+/** 커서 기반 무한스크롤 흉내 — 한 번에 노출할 개수 */
+const PAGE_SIZE = 4
+
+interface UseApplicationListOptions {
+ /** 특정 공고의 지원자만 조회 (공고 상세 → 지원자 보기) */
+ postingId?: number
+}
+
+/** 지원자 목록 — 업장·상태 필터 + 무한스크롤 */
+export function useApplicationListViewModel({
+ postingId,
+}: UseApplicationListOptions = {}) {
+ const applications = useMockPostingStore(state => state.applications)
+
+ const [workspaceFilter, setWorkspaceFilterState] =
+ useState(ALL_WORKSPACES)
+ const [statusFilter, setStatusFilterState] =
+ useState('ALL')
+ const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
+ const [isLoading, setIsLoading] = useState(true)
+
+ useEffect(() => {
+ const timer = setTimeout(() => setIsLoading(false), MOCK_LATENCY)
+ return () => clearTimeout(timer)
+ }, [])
+
+ // 필터가 바뀌면 페이지를 처음으로 되돌림
+ const setWorkspaceFilter = useCallback((value: WorkspaceFilter) => {
+ setWorkspaceFilterState(value)
+ setVisibleCount(PAGE_SIZE)
+ }, [])
+
+ const setStatusFilter = useCallback((value: ApplicationStatusFilter) => {
+ setStatusFilterState(value)
+ setVisibleCount(PAGE_SIZE)
+ }, [])
+
+ const filteredApplications = useMemo(
+ () =>
+ applications.filter(application => {
+ if (postingId !== undefined && application.postingId !== postingId) {
+ return false
+ }
+ if (
+ workspaceFilter !== ALL_WORKSPACES &&
+ application.workspaceId !== workspaceFilter
+ ) {
+ return false
+ }
+ if (statusFilter !== 'ALL' && application.status !== statusFilter) {
+ return false
+ }
+ return true
+ }),
+ [applications, postingId, workspaceFilter, statusFilter]
+ )
+
+ const visibleApplications = filteredApplications.slice(0, visibleCount)
+ const hasNextPage = visibleCount < filteredApplications.length
+
+ const fetchNextPage = useCallback(() => {
+ setVisibleCount(prev => prev + PAGE_SIZE)
+ }, [])
+
+ const workspaceOptions = useMemo(
+ () => [
+ { value: ALL_WORKSPACES as WorkspaceFilter, label: '전체 업장' },
+ ...MOCK_WORKSPACES.map(workspace => ({
+ value: workspace.id as WorkspaceFilter,
+ label: workspace.businessName,
+ })),
+ ],
+ []
+ )
+
+ return {
+ applications: visibleApplications,
+ totalCount: filteredApplications.length,
+ isLoading,
+ isEmpty: !isLoading && filteredApplications.length === 0,
+ hasNextPage,
+ fetchNextPage,
+ workspaceFilter,
+ setWorkspaceFilter,
+ statusFilter,
+ setStatusFilter,
+ workspaceOptions,
+ }
+}
diff --git a/src/features/manager/posting/hooks/usePostingDetailViewModel.ts b/src/features/manager/posting/hooks/usePostingDetailViewModel.ts
new file mode 100644
index 00000000..2e8591f7
--- /dev/null
+++ b/src/features/manager/posting/hooks/usePostingDetailViewModel.ts
@@ -0,0 +1,40 @@
+import { useMemo, useState } from 'react'
+
+import { useMockPostingStore } from '@/features/manager/posting/mocks/store'
+import { showToast } from '@/shared/stores/useToastStore'
+
+/** 공고 상세 — 조회 + 모집 마감 처리 */
+export function usePostingDetailViewModel(postingId: number) {
+ const postings = useMockPostingStore(state => state.postings)
+ const applications = useMockPostingStore(state => state.applications)
+ const closePosting = useMockPostingStore(state => state.closePosting)
+
+ const [isCloseModalOpen, setIsCloseModalOpen] = useState(false)
+
+ const posting = useMemo(
+ () => postings.find(item => item.id === postingId) ?? null,
+ [postings, postingId]
+ )
+
+ // 목업 지원자 수 — 실제 API에서는 공고 상세 응답 필드 사용
+ const applicantCount = useMemo(
+ () => applications.filter(item => item.postingId === postingId).length,
+ [applications, postingId]
+ )
+
+ const confirmClose = () => {
+ closePosting(postingId)
+ setIsCloseModalOpen(false)
+ showToast('모집을 마감했어요')
+ }
+
+ return {
+ posting,
+ applicantCount,
+ isNotFound: posting === null,
+ isCloseModalOpen,
+ openCloseModal: () => setIsCloseModalOpen(true),
+ closeCloseModal: () => setIsCloseModalOpen(false),
+ confirmClose,
+ }
+}
diff --git a/src/features/manager/posting/hooks/usePostingForm.ts b/src/features/manager/posting/hooks/usePostingForm.ts
new file mode 100644
index 00000000..bb93cedf
--- /dev/null
+++ b/src/features/manager/posting/hooks/usePostingForm.ts
@@ -0,0 +1,221 @@
+import { useCallback, useMemo, useState } from 'react'
+
+import {
+ MAX_KEYWORDS,
+ type PaymentType,
+ type Posting,
+ type PostingFormErrors,
+ type PostingFormSchedule,
+ type PostingFormValues,
+ type WorkingDay,
+} from '@/features/manager/posting/types/posting'
+
+let scheduleKeySeq = 0
+
+function createEmptySchedule(): PostingFormSchedule {
+ scheduleKeySeq += 1
+ return {
+ key: `new-${scheduleKeySeq}`,
+ id: null,
+ workingDays: [],
+ startTime: '',
+ endTime: '',
+ position: '',
+ positionsNeeded: 1,
+ }
+}
+
+function createInitialValues(posting?: Posting | null): PostingFormValues {
+ if (!posting) {
+ return {
+ workspaceId: null,
+ title: '',
+ keywords: [],
+ schedules: [createEmptySchedule()],
+ paymentType: 'HOURLY',
+ payAmount: '',
+ description: '',
+ }
+ }
+
+ return {
+ workspaceId: posting.workspaceId,
+ title: posting.title,
+ keywords: [...posting.keywords],
+ schedules: posting.schedules.map(schedule => {
+ scheduleKeySeq += 1
+ return {
+ ...schedule,
+ workingDays: [...schedule.workingDays],
+ key: `existing-${schedule.id ?? scheduleKeySeq}`,
+ }
+ }),
+ paymentType: posting.paymentType,
+ payAmount: String(posting.payAmount),
+ description: posting.description,
+ }
+}
+
+function validate(values: PostingFormValues): PostingFormErrors {
+ const errors: PostingFormErrors = {}
+
+ if (values.workspaceId === null) {
+ errors.workspaceId = '업장을 선택해 주세요'
+ }
+ if (values.title.trim() === '') {
+ errors.title = '공고 제목을 입력해 주세요'
+ }
+ if (values.keywords.length === 0) {
+ errors.keywords = '업직종을 1개 이상 선택해 주세요'
+ }
+
+ const hasIncompleteSchedule = values.schedules.some(
+ schedule =>
+ schedule.workingDays.length === 0 ||
+ schedule.startTime === '' ||
+ schedule.endTime === ''
+ )
+ if (values.schedules.length === 0) {
+ errors.schedules = '근무일정을 1개 이상 추가해 주세요'
+ } else if (hasIncompleteSchedule) {
+ errors.schedules = '근무요일과 시작·종료 시간을 모두 입력해 주세요'
+ }
+
+ const payAmount = Number(values.payAmount.replace(/[^0-9]/g, ''))
+ if (values.payAmount.trim() === '' || payAmount <= 0) {
+ errors.payAmount = '급여를 입력해 주세요'
+ }
+
+ return errors
+}
+
+interface UsePostingFormOptions {
+ /** 수정 모드일 때 프리필할 공고 */
+ posting?: Posting | null
+}
+
+/**
+ * 공고 등록·수정 공용 폼 view-model.
+ * 프로젝트 관행에 따라 react-hook-form/zod 대신 useState 기반으로 구성합니다
+ * (참고: features/store-register/hooks/useStoreRegisterWizard.ts).
+ */
+export function usePostingForm({ posting }: UsePostingFormOptions = {}) {
+ const isEditMode = Boolean(posting)
+ const [values, setValues] = useState(() =>
+ createInitialValues(posting)
+ )
+ /** 제출을 한 번이라도 시도했을 때만 에러를 노출 */
+ const [isSubmitAttempted, setIsSubmitAttempted] = useState(false)
+
+ const errors = useMemo(() => validate(values), [values])
+ const isValid = Object.keys(errors).length === 0
+ const visibleErrors: PostingFormErrors = isSubmitAttempted ? errors : {}
+ /**
+ * 제출 전에는 버튼을 활성화해 두고, 클릭 시 검증 에러를 노출합니다.
+ * (처음부터 비활성화하면 무엇이 비었는지 알 수 없어 막다른 길이 됩니다)
+ */
+ const isSubmitDisabled = isSubmitAttempted && !isValid
+
+ const setWorkspaceId = useCallback((workspaceId: number) => {
+ setValues(prev => ({ ...prev, workspaceId }))
+ }, [])
+
+ const setTitle = useCallback((title: string) => {
+ setValues(prev => ({ ...prev, title }))
+ }, [])
+
+ const toggleKeyword = useCallback((keyword: string) => {
+ setValues(prev => {
+ if (prev.keywords.includes(keyword)) {
+ return {
+ ...prev,
+ keywords: prev.keywords.filter(item => item !== keyword),
+ }
+ }
+ if (prev.keywords.length >= MAX_KEYWORDS) return prev
+ return { ...prev, keywords: [...prev.keywords, keyword] }
+ })
+ }, [])
+
+ const setPaymentType = useCallback((paymentType: PaymentType) => {
+ setValues(prev => ({ ...prev, paymentType }))
+ }, [])
+
+ const setPayAmount = useCallback((payAmount: string) => {
+ // 숫자만 허용 후 천 단위 구분 표시는 UI에서 처리
+ setValues(prev => ({
+ ...prev,
+ payAmount: payAmount.replace(/[^0-9]/g, ''),
+ }))
+ }, [])
+
+ const setDescription = useCallback((description: string) => {
+ setValues(prev => ({ ...prev, description }))
+ }, [])
+
+ const addSchedule = useCallback(() => {
+ setValues(prev => ({
+ ...prev,
+ schedules: [...prev.schedules, createEmptySchedule()],
+ }))
+ }, [])
+
+ const removeSchedule = useCallback((key: string) => {
+ setValues(prev => ({
+ ...prev,
+ schedules: prev.schedules.filter(schedule => schedule.key !== key),
+ }))
+ }, [])
+
+ const updateSchedule = useCallback(
+ (key: string, patch: Partial>) => {
+ setValues(prev => ({
+ ...prev,
+ schedules: prev.schedules.map(schedule =>
+ schedule.key === key ? { ...schedule, ...patch } : schedule
+ ),
+ }))
+ },
+ []
+ )
+
+ const toggleScheduleDay = useCallback((key: string, day: WorkingDay) => {
+ setValues(prev => ({
+ ...prev,
+ schedules: prev.schedules.map(schedule => {
+ if (schedule.key !== key) return schedule
+ const workingDays = schedule.workingDays.includes(day)
+ ? schedule.workingDays.filter(item => item !== day)
+ : [...schedule.workingDays, day]
+ return { ...schedule, workingDays }
+ }),
+ }))
+ }, [])
+
+ /** 제출 시도 — 유효하면 true를 반환하고, 아니면 에러를 노출합니다 */
+ const attemptSubmit = useCallback(() => {
+ setIsSubmitAttempted(true)
+ return Object.keys(validate(values)).length === 0
+ }, [values])
+
+ return {
+ values,
+ errors: visibleErrors,
+ isValid,
+ isSubmitDisabled,
+ isEditMode,
+ setWorkspaceId,
+ setTitle,
+ toggleKeyword,
+ setPaymentType,
+ setPayAmount,
+ setDescription,
+ addSchedule,
+ removeSchedule,
+ updateSchedule,
+ toggleScheduleDay,
+ attemptSubmit,
+ }
+}
+
+export type PostingFormViewModel = ReturnType
diff --git a/src/features/manager/posting/hooks/usePostingListViewModel.ts b/src/features/manager/posting/hooks/usePostingListViewModel.ts
new file mode 100644
index 00000000..00148dbc
--- /dev/null
+++ b/src/features/manager/posting/hooks/usePostingListViewModel.ts
@@ -0,0 +1,67 @@
+import { useEffect, useMemo, useState } from 'react'
+
+import { MOCK_WORKSPACES } from '@/features/manager/posting/mocks/data'
+import {
+ MOCK_LATENCY,
+ useMockPostingStore,
+} from '@/features/manager/posting/mocks/store'
+import type { PostingStatusFilter } from '@/features/manager/posting/lib/postingStatus'
+
+export const ALL_WORKSPACES = 'ALL' as const
+export type WorkspaceFilter = number | typeof ALL_WORKSPACES
+
+/** 내 공고 목록 — 업장·상태 필터 + 로딩(스켈레톤) 상태 */
+export function usePostingListViewModel() {
+ const postings = useMockPostingStore(state => state.postings)
+
+ const [workspaceFilter, setWorkspaceFilter] =
+ useState(ALL_WORKSPACES)
+ const [statusFilter, setStatusFilter] = useState('ALL')
+ const [isLoading, setIsLoading] = useState(true)
+
+ // 목업 로딩 지연 — 스켈레톤 확인용
+ useEffect(() => {
+ const timer = setTimeout(() => setIsLoading(false), MOCK_LATENCY)
+ return () => clearTimeout(timer)
+ }, [])
+
+ const filteredPostings = useMemo(
+ () =>
+ postings.filter(posting => {
+ if (
+ workspaceFilter !== ALL_WORKSPACES &&
+ posting.workspaceId !== workspaceFilter
+ ) {
+ return false
+ }
+ if (statusFilter !== 'ALL' && posting.status !== statusFilter) {
+ return false
+ }
+ return true
+ }),
+ [postings, workspaceFilter, statusFilter]
+ )
+
+ const workspaceOptions = useMemo(
+ () => [
+ { value: ALL_WORKSPACES as WorkspaceFilter, label: '전체 업장' },
+ ...MOCK_WORKSPACES.map(workspace => ({
+ value: workspace.id as WorkspaceFilter,
+ label: workspace.businessName,
+ })),
+ ],
+ []
+ )
+
+ return {
+ postings: filteredPostings,
+ totalCount: filteredPostings.length,
+ isLoading,
+ isEmpty: !isLoading && filteredPostings.length === 0,
+ workspaceFilter,
+ setWorkspaceFilter,
+ statusFilter,
+ setStatusFilter,
+ workspaceOptions,
+ }
+}
diff --git a/src/features/manager/posting/lib/applicationStatus.ts b/src/features/manager/posting/lib/applicationStatus.ts
new file mode 100644
index 00000000..892e28db
--- /dev/null
+++ b/src/features/manager/posting/lib/applicationStatus.ts
@@ -0,0 +1,117 @@
+import type { ApplicationStatus } from '@/features/manager/posting/types/posting'
+
+interface ApplicationStatusBadgeStyle {
+ label: string
+ containerClassName: string
+ textClassName: string
+}
+
+/**
+ * 지원 상태 배지 (PDF ApplicationStatusBadge).
+ * 기존 `shared/ui/home/ApplicationStatusBadge`는 유저 측 4상태 전용이므로
+ * 매니저 측 6상태는 여기서 별도로 매핑합니다.
+ */
+const APPLICATION_STATUS_BADGE: Record<
+ ApplicationStatus,
+ ApplicationStatusBadgeStyle
+> = {
+ SUBMITTED: {
+ label: '지원완료',
+ containerClassName: 'bg-white border border-line-1',
+ textClassName: 'text-text-70',
+ },
+ SHORTLISTED: {
+ label: '서류합격',
+ containerClassName: 'bg-main-100 border border-main-100',
+ textClassName: 'text-sub',
+ },
+ ACCEPTED: {
+ label: '최종합격',
+ containerClassName: 'bg-main border border-main',
+ textClassName: 'text-white',
+ },
+ REJECTED: {
+ label: '불합격',
+ containerClassName: 'bg-white border border-error',
+ textClassName: 'text-error',
+ },
+ CANCELLED: {
+ label: '취소',
+ containerClassName: 'bg-bg-dark border border-bg-dark',
+ textClassName: 'text-text-50',
+ },
+ EXPIRED: {
+ label: '만료',
+ containerClassName: 'bg-bg-dark border border-bg-dark',
+ textClassName: 'text-text-50',
+ },
+ DELETED: {
+ label: '삭제됨',
+ containerClassName: 'bg-bg-dark border border-bg-dark',
+ textClassName: 'text-text-50',
+ },
+}
+
+export function resolveApplicationStatusBadge(status: ApplicationStatus) {
+ return APPLICATION_STATUS_BADGE[status]
+}
+
+/** 더 이상 상태를 변경할 수 없는(종료된) 지원서 — 채용 결정 액션을 숨깁니다 */
+const TERMINAL_STATUSES: ApplicationStatus[] = [
+ 'ACCEPTED',
+ 'REJECTED',
+ 'CANCELLED',
+ 'EXPIRED',
+ 'DELETED',
+]
+
+export function isTerminalApplicationStatus(status: ApplicationStatus) {
+ return TERMINAL_STATUSES.includes(status)
+}
+
+/** 채용 결정 액션 — PDF 하단 ActionBar */
+export const HIRING_DECISIONS = [
+ { status: 'SHORTLISTED', label: '서류합격' },
+ { status: 'ACCEPTED', label: '최종합격' },
+ { status: 'REJECTED', label: '불합격' },
+] as const
+
+export type HiringDecision = (typeof HIRING_DECISIONS)[number]['status']
+
+interface DecisionCopy {
+ title: string
+ description?: string
+ toast: string
+}
+
+/** 채용 결정 확인 모달 · 성공 Toast 문구 */
+export const DECISION_COPY: Record = {
+ SHORTLISTED: {
+ title: '서류합격 처리할까요?',
+ description: '지원자에게 서류합격 결과가 전달됩니다.',
+ toast: '서류합격 처리됐어요',
+ },
+ ACCEPTED: {
+ title: '최종합격 처리할까요?',
+ description: '최종합격 시 해당 지원자가 업장 근무자로 자동 등록됩니다.',
+ toast: '최종합격 처리됐어요',
+ },
+ REJECTED: {
+ title: '불합격 처리할까요?',
+ description: '불합격 처리 후에는 상태를 변경할 수 없어요.',
+ toast: '불합격 처리됐어요',
+ },
+}
+
+export const APPLICATION_STATUS_FILTER_OPTIONS = [
+ { value: 'ALL', label: '전체 상태' },
+ { value: 'SUBMITTED', label: '지원완료' },
+ { value: 'SHORTLISTED', label: '서류합격' },
+ { value: 'ACCEPTED', label: '최종합격' },
+ { value: 'REJECTED', label: '불합격' },
+ { value: 'CANCELLED', label: '취소' },
+ { value: 'EXPIRED', label: '만료' },
+] as const
+
+export type ApplicationStatusFilter =
+ (typeof APPLICATION_STATUS_FILTER_OPTIONS)[number]['value']
diff --git a/src/features/manager/posting/lib/postingStatus.ts b/src/features/manager/posting/lib/postingStatus.ts
new file mode 100644
index 00000000..b32b4742
--- /dev/null
+++ b/src/features/manager/posting/lib/postingStatus.ts
@@ -0,0 +1,37 @@
+import type { PostingStatus } from '@/features/manager/posting/types/posting'
+
+interface PostingStatusBadgeStyle {
+ label: string
+ containerClassName: string
+ textClassName: string
+}
+
+/**
+ * 공고 상태 배지 스타일.
+ * PDF 기준: 마감임박(warning)은 이번 범위에서 미사용 — API에 마감일 필드 근거 없음.
+ */
+const POSTING_STATUS_BADGE: Record = {
+ OPEN: {
+ label: '모집중',
+ containerClassName: 'bg-main-100',
+ textClassName: 'text-sub',
+ },
+ CLOSED: {
+ label: '모집완료',
+ containerClassName: 'bg-bg-dark',
+ textClassName: 'text-text-70',
+ },
+}
+
+export function resolvePostingStatusBadge(status: PostingStatus) {
+ return POSTING_STATUS_BADGE[status]
+}
+
+export const POSTING_STATUS_FILTER_OPTIONS = [
+ { value: 'ALL', label: '전체 상태' },
+ { value: 'OPEN', label: '모집중' },
+ { value: 'CLOSED', label: '모집완료' },
+] as const
+
+export type PostingStatusFilter =
+ (typeof POSTING_STATUS_FILTER_OPTIONS)[number]['value']
diff --git a/src/features/manager/posting/mocks/data.ts b/src/features/manager/posting/mocks/data.ts
new file mode 100644
index 00000000..4afb79bb
--- /dev/null
+++ b/src/features/manager/posting/mocks/data.ts
@@ -0,0 +1,264 @@
+/**
+ * API 미연동 단계용 목업 데이터.
+ * 추후 `features/manager/posting/api/`의 axios 호출로 교체됩니다.
+ */
+import type {
+ Application,
+ Posting,
+ Workspace,
+} from '@/features/manager/posting/types/posting'
+
+export const MOCK_WORKSPACES: Workspace[] = [
+ { id: 1, businessName: '알터 강남점', businessType: '카페' },
+ { id: 2, businessName: '알터 성수 로스터리', businessType: '카페' },
+]
+
+/** 업직종 키워드 — GET /manager/postings/available-keywords 대체 */
+export const MOCK_KEYWORDS: string[] = [
+ '카페',
+ '홀서빙',
+ '바리스타',
+ '베이킹',
+ '주방보조',
+ '마감청소',
+ '캐셔',
+ '배달',
+]
+
+export const MOCK_POSTINGS: Posting[] = [
+ {
+ id: 1,
+ workspaceId: 1,
+ workspaceName: '알터 강남점',
+ businessType: '카페',
+ title: '주말 홀서빙 · 오후 마감조 구합니다',
+ description:
+ '주말 오후 마감조 홀서빙 아르바이트를 구합니다. 성실하게 근무하실 분 환영해요. 경험자 우대하며, 미경험자도 교육 후 근무 가능합니다.',
+ keywords: ['카페', '홀서빙'],
+ paymentType: 'HOURLY',
+ payAmount: 12000,
+ status: 'OPEN',
+ applicantCount: 6,
+ createdAt: '2026-07-20T09:00:00.000Z',
+ schedules: [
+ {
+ id: 101,
+ workingDays: ['SATURDAY', 'SUNDAY'],
+ startTime: '17:00',
+ endTime: '22:00',
+ position: '홀서빙',
+ positionsNeeded: 2,
+ },
+ {
+ id: 102,
+ workingDays: ['FRIDAY', 'SATURDAY'],
+ startTime: '21:00',
+ endTime: '23:00',
+ position: '마감 청소',
+ positionsNeeded: 1,
+ },
+ ],
+ },
+ {
+ id: 2,
+ workspaceId: 1,
+ workspaceName: '알터 강남점',
+ businessType: '카페',
+ title: '평일 오픈 바리스타',
+ description:
+ '평일 오픈조 바리스타를 모집합니다. 바리스타 자격증 보유자 우대합니다.',
+ keywords: ['카페', '바리스타'],
+ paymentType: 'HOURLY',
+ payAmount: 12500,
+ status: 'OPEN',
+ applicantCount: 3,
+ createdAt: '2026-07-18T09:00:00.000Z',
+ schedules: [
+ {
+ id: 201,
+ workingDays: ['MONDAY', 'WEDNESDAY', 'FRIDAY'],
+ startTime: '07:00',
+ endTime: '12:00',
+ position: '바리스타',
+ positionsNeeded: 1,
+ },
+ ],
+ },
+ {
+ id: 3,
+ workspaceId: 2,
+ workspaceName: '알터 성수 로스터리',
+ businessType: '카페',
+ title: '여름 성수기 주말 단기',
+ description: '여름 성수기 주말 단기 아르바이트를 모집합니다.',
+ keywords: ['카페', '베이킹'],
+ paymentType: 'DAILY',
+ payAmount: 90000,
+ status: 'CLOSED',
+ applicantCount: 4,
+ createdAt: '2026-07-05T09:00:00.000Z',
+ schedules: [
+ {
+ id: 301,
+ workingDays: ['SATURDAY', 'SUNDAY'],
+ startTime: '13:00',
+ endTime: '18:00',
+ position: '베이킹 보조',
+ positionsNeeded: 2,
+ },
+ ],
+ },
+]
+
+export const MOCK_APPLICATIONS: Application[] = [
+ {
+ id: 1,
+ postingId: 1,
+ workspaceId: 1,
+ workspaceName: '알터 강남점',
+ status: 'SUBMITTED',
+ appliedAt: '2026-07-26T07:00:00.000Z',
+ schedule: {
+ workingDays: ['SATURDAY', 'SUNDAY'],
+ startTime: '17:00',
+ endTime: '22:00',
+ position: '홀서빙',
+ },
+ description:
+ '카페 홀 경험 1년 있습니다. 주말 마감조 성실하게 근무 가능하고, 바리스타 자격증도 보유하고 있어 음료 제조도 도와드릴 수 있습니다. 잘 부탁드립니다!',
+ applicant: {
+ name: '김지원',
+ phoneNumber: '010-2345-6789',
+ birthDate: '2001.03.14',
+ gender: '여성',
+ email: 'jiwon.k@example.com',
+ certificates: [
+ { name: '바리스타 2급', issuer: '한국커피협회', acquiredAt: '2023.08' },
+ {
+ name: '위생교육 이수증',
+ issuer: '식품안전정보원',
+ acquiredAt: '2024.02',
+ },
+ ],
+ },
+ },
+ {
+ id: 2,
+ postingId: 2,
+ workspaceId: 1,
+ workspaceName: '알터 강남점',
+ status: 'SHORTLISTED',
+ appliedAt: '2026-07-26T04:00:00.000Z',
+ schedule: {
+ workingDays: ['MONDAY', 'WEDNESDAY', 'FRIDAY'],
+ startTime: '09:00',
+ endTime: '14:00',
+ position: '바리스타',
+ },
+ description:
+ '바리스타로 2년간 근무했습니다. 평일 오전 근무 가능하며 오픈 업무 경험이 많습니다.',
+ applicant: {
+ name: '이서준',
+ phoneNumber: '010-3456-7890',
+ birthDate: '1998.06.21',
+ gender: '남성',
+ email: 'seojun.lee@example.com',
+ certificates: [
+ { name: '바리스타 1급', issuer: '한국커피협회', acquiredAt: '2022.05' },
+ ],
+ },
+ },
+ {
+ id: 3,
+ postingId: 3,
+ workspaceId: 2,
+ workspaceName: '알터 성수 로스터리',
+ status: 'ACCEPTED',
+ appliedAt: '2026-07-25T09:00:00.000Z',
+ schedule: {
+ workingDays: ['SATURDAY', 'SUNDAY'],
+ startTime: '13:00',
+ endTime: '18:00',
+ position: '베이킹 보조',
+ },
+ description:
+ '베이커리 아르바이트 경험이 있어 빠르게 적응할 수 있습니다. 주말 오후 근무 가능합니다.',
+ applicant: {
+ name: '박하늘',
+ phoneNumber: '010-8765-4321',
+ birthDate: '1999.11.02',
+ gender: '여성',
+ email: 'haneul@example.com',
+ certificates: [],
+ },
+ },
+ {
+ id: 4,
+ postingId: 1,
+ workspaceId: 1,
+ workspaceName: '알터 강남점',
+ status: 'REJECTED',
+ appliedAt: '2026-07-24T09:00:00.000Z',
+ schedule: {
+ workingDays: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY'],
+ startTime: '18:00',
+ endTime: '22:00',
+ position: '마감조',
+ },
+ description: '평일 저녁 근무 가능합니다. 성실하게 근무하겠습니다.',
+ applicant: {
+ name: '정민재',
+ phoneNumber: '010-1122-3344',
+ birthDate: '2000.01.09',
+ gender: '남성',
+ email: 'minjae.j@example.com',
+ certificates: [],
+ },
+ },
+ {
+ id: 5,
+ postingId: 2,
+ workspaceId: 1,
+ workspaceName: '알터 강남점',
+ status: 'CANCELLED',
+ appliedAt: '2026-07-23T09:00:00.000Z',
+ schedule: {
+ workingDays: ['MONDAY', 'WEDNESDAY'],
+ startTime: '07:00',
+ endTime: '12:00',
+ position: '바리스타',
+ },
+ description: '오전 근무 가능합니다.',
+ applicant: {
+ name: '최유나',
+ phoneNumber: '010-5566-7788',
+ birthDate: '2002.04.18',
+ gender: '여성',
+ email: 'yuna.choi@example.com',
+ certificates: [],
+ },
+ },
+ {
+ id: 6,
+ postingId: 3,
+ workspaceId: 2,
+ workspaceName: '알터 성수 로스터리',
+ status: 'EXPIRED',
+ appliedAt: '2026-07-20T09:00:00.000Z',
+ schedule: {
+ workingDays: ['SATURDAY'],
+ startTime: '13:00',
+ endTime: '18:00',
+ position: '베이킹 보조',
+ },
+ description: '주말 단기 근무 희망합니다.',
+ applicant: {
+ name: '한소희',
+ phoneNumber: '010-9900-1122',
+ birthDate: '1997.09.30',
+ gender: '여성',
+ email: 'sohee.han@example.com',
+ certificates: [],
+ },
+ },
+]
diff --git a/src/features/manager/posting/mocks/store.ts b/src/features/manager/posting/mocks/store.ts
new file mode 100644
index 00000000..1894b914
--- /dev/null
+++ b/src/features/manager/posting/mocks/store.ts
@@ -0,0 +1,121 @@
+/**
+ * 목업 인메모리 스토어.
+ *
+ * API 연동 전까지 화면 간 상태(등록/수정/마감/채용 결정)를 유지하기 위한 임시 저장소입니다.
+ * 실제 API 연동 시 이 파일과 `mocks/data.ts`는 삭제하고,
+ * 각 view-model 훅을 react-query(useQuery/useMutation)로 교체하면 됩니다.
+ */
+import { create } from 'zustand'
+
+import {
+ MOCK_APPLICATIONS,
+ MOCK_POSTINGS,
+} from '@/features/manager/posting/mocks/data'
+import type {
+ Application,
+ ApplicationStatus,
+ Posting,
+ PostingFormSchedule,
+ PostingFormValues,
+ PostingSchedule,
+} from '@/features/manager/posting/types/posting'
+
+/** 네트워크 지연 흉내 — 스켈레톤/로딩 상태를 확인하기 위함 */
+export const MOCK_LATENCY = 400
+
+interface MockPostingState {
+ postings: Posting[]
+ applications: Application[]
+ createPosting: (values: PostingFormValues, workspaceName: string) => number
+ updatePosting: (postingId: number, values: PostingFormValues) => void
+ closePosting: (postingId: number) => void
+ updateApplicationStatus: (
+ applicationId: number,
+ status: ApplicationStatus
+ ) => void
+}
+
+let nextPostingId = MOCK_POSTINGS.length + 1
+
+function toPayAmount(payAmount: string): number {
+ const parsed = Number(payAmount.replace(/[^0-9]/g, ''))
+ return Number.isNaN(parsed) ? 0 : parsed
+}
+
+/** 폼 전용 필드(key)를 제거해 저장용 일정으로 변환 */
+function toPostingSchedules(
+ schedules: PostingFormSchedule[]
+): PostingSchedule[] {
+ return schedules.map(schedule => ({
+ id: schedule.id,
+ workingDays: schedule.workingDays,
+ startTime: schedule.startTime,
+ endTime: schedule.endTime,
+ position: schedule.position,
+ positionsNeeded: schedule.positionsNeeded,
+ }))
+}
+
+export const useMockPostingStore = create((set, get) => ({
+ postings: MOCK_POSTINGS,
+ applications: MOCK_APPLICATIONS,
+
+ createPosting: (values, workspaceName) => {
+ nextPostingId += 1
+ const id = nextPostingId
+ const posting: Posting = {
+ id,
+ workspaceId: values.workspaceId ?? 0,
+ workspaceName,
+ businessType: '카페',
+ title: values.title.trim(),
+ description: values.description.trim(),
+ keywords: values.keywords,
+ paymentType: values.paymentType,
+ payAmount: toPayAmount(values.payAmount),
+ status: 'OPEN',
+ applicantCount: 0,
+ createdAt: new Date().toISOString(),
+ schedules: toPostingSchedules(values.schedules),
+ }
+ set(state => ({ postings: [posting, ...state.postings] }))
+ return id
+ },
+
+ updatePosting: (postingId, values) => {
+ set(state => ({
+ postings: state.postings.map(posting =>
+ posting.id === postingId
+ ? {
+ ...posting,
+ title: values.title.trim(),
+ description: values.description.trim(),
+ keywords: values.keywords,
+ paymentType: values.paymentType,
+ payAmount: toPayAmount(values.payAmount),
+ schedules: toPostingSchedules(values.schedules),
+ }
+ : posting
+ ),
+ }))
+ },
+
+ closePosting: postingId => {
+ set(state => ({
+ postings: state.postings.map(posting =>
+ posting.id === postingId ? { ...posting, status: 'CLOSED' } : posting
+ ),
+ }))
+ },
+
+ updateApplicationStatus: (applicationId, status) => {
+ const { applications } = get()
+ set({
+ applications: applications.map(application =>
+ application.id === applicationId
+ ? { ...application, status }
+ : application
+ ),
+ })
+ },
+}))
diff --git a/src/features/manager/posting/types/posting.ts b/src/features/manager/posting/types/posting.ts
new file mode 100644
index 00000000..bfc31425
--- /dev/null
+++ b/src/features/manager/posting/types/posting.ts
@@ -0,0 +1,178 @@
+/**
+ * 사장님 구인구직 — 매니저 측 공고/지원자 UI 모델
+ *
+ * 서버 DTO(`@/features/manager/home/types/posting`)와 지원 상태 enum
+ * (`@/features/user/home/applied-stores/types/application`)을 계승합니다.
+ * API 미연동 단계이므로 화면이 소비하는 UI 모델을 이 파일에서 정의합니다.
+ */
+import type { ApplicationApiStatus } from '@/features/user/home/applied-stores/types/application'
+
+// ---- 요일 ----
+export const WORKING_DAYS = [
+ 'MONDAY',
+ 'TUESDAY',
+ 'WEDNESDAY',
+ 'THURSDAY',
+ 'FRIDAY',
+ 'SATURDAY',
+ 'SUNDAY',
+] as const
+
+export type WorkingDay = (typeof WORKING_DAYS)[number]
+
+export const WORKING_DAY_LABEL: Record = {
+ MONDAY: '월',
+ TUESDAY: '화',
+ WEDNESDAY: '수',
+ THURSDAY: '목',
+ FRIDAY: '금',
+ SATURDAY: '토',
+ SUNDAY: '일',
+}
+
+// ---- 급여 ----
+export const PAYMENT_TYPES = ['HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY'] as const
+export type PaymentType = (typeof PAYMENT_TYPES)[number]
+
+export const PAYMENT_TYPE_LABEL: Record = {
+ HOURLY: '시급',
+ DAILY: '일급',
+ WEEKLY: '주급',
+ MONTHLY: '월급',
+}
+
+// ---- 공고 상태 ----
+/** OPEN=모집중, CLOSED=모집완료 */
+export type PostingStatus = 'OPEN' | 'CLOSED'
+
+// ---- 지원 상태 ----
+/** 서버 enum을 그대로 사용 (SUBMITTED/SHORTLISTED/ACCEPTED/REJECTED/CANCELLED/EXPIRED/DELETED) */
+export type ApplicationStatus = ApplicationApiStatus
+
+// ---- UI 모델 ----
+export interface PostingSchedule {
+ /** 기존 일정은 서버 id, 신규 추가분은 null */
+ id: number | null
+ workingDays: WorkingDay[]
+ startTime: string
+ endTime: string
+ position: string
+ positionsNeeded: number
+}
+
+export interface Workspace {
+ id: number
+ businessName: string
+ businessType: string
+}
+
+export interface Posting {
+ id: number
+ workspaceId: number
+ workspaceName: string
+ businessType: string
+ title: string
+ description: string
+ keywords: string[]
+ paymentType: PaymentType
+ payAmount: number
+ status: PostingStatus
+ schedules: PostingSchedule[]
+ applicantCount: number
+ createdAt: string
+}
+
+export interface Certificate {
+ name: string
+ issuer: string
+ acquiredAt: string
+}
+
+export interface Applicant {
+ name: string
+ phoneNumber: string
+ birthDate: string
+ gender: '남성' | '여성'
+ email: string
+ certificates: Certificate[]
+}
+
+export interface Application {
+ id: number
+ postingId: number
+ workspaceId: number
+ workspaceName: string
+ status: ApplicationStatus
+ /** 지원 시각 (ISO) — 목록에서 '2시간 전' 형태로 표시 */
+ appliedAt: string
+ applicant: Applicant
+ /** 지원한 근무일정 */
+ schedule: Pick<
+ PostingSchedule,
+ 'workingDays' | 'startTime' | 'endTime' | 'position'
+ >
+ /** 지원 메시지 */
+ description: string
+}
+
+// ---- 폼 모델 ----
+export interface PostingFormSchedule extends PostingSchedule {
+ /** 폼 내부에서 일정 카드를 구분하기 위한 로컬 키 */
+ key: string
+}
+
+export interface PostingFormValues {
+ workspaceId: number | null
+ title: string
+ keywords: string[]
+ schedules: PostingFormSchedule[]
+ paymentType: PaymentType
+ payAmount: string
+ description: string
+}
+
+/** 필드별 검증 에러 — 값이 있으면 해당 필드에 에러 표시 */
+export interface PostingFormErrors {
+ workspaceId?: string
+ title?: string
+ keywords?: string
+ schedules?: string
+ payAmount?: string
+}
+
+/** 공고당 선택 가능한 최대 키워드 수 */
+export const MAX_KEYWORDS = 3
+
+// ---- 포맷터 ----
+export function formatPay(paymentType: PaymentType, payAmount: number): string {
+ return `${PAYMENT_TYPE_LABEL[paymentType]} ${payAmount.toLocaleString('ko-KR')}원`
+}
+
+export function formatWorkingDays(days: WorkingDay[]): string {
+ if (days.length === 0) return '-'
+ return WORKING_DAYS.filter(day => days.includes(day))
+ .map(day => WORKING_DAY_LABEL[day])
+ .join('·')
+}
+
+export function formatTimeRange(startTime: string, endTime: string): string {
+ return `${startTime}~${endTime}`
+}
+
+/** 지원 시각을 '방금 전 / N시간 전 / 어제 / N일 전' 형태로 변환 */
+export function formatRelativeTime(isoDate: string, now = new Date()): string {
+ const target = new Date(isoDate)
+ const diffMs = now.getTime() - target.getTime()
+ if (Number.isNaN(diffMs)) return ''
+
+ const diffMinutes = Math.floor(diffMs / (1000 * 60))
+ if (diffMinutes < 1) return '방금 전'
+ if (diffMinutes < 60) return `${diffMinutes}분 전`
+
+ const diffHours = Math.floor(diffMinutes / 60)
+ if (diffHours < 24) return `${diffHours}시간 전`
+
+ const diffDays = Math.floor(diffHours / 24)
+ if (diffDays === 1) return '어제'
+ return `${diffDays}일 전`
+}
diff --git a/src/features/manager/posting/ui/ApplicantCard.tsx b/src/features/manager/posting/ui/ApplicantCard.tsx
new file mode 100644
index 00000000..c189969a
--- /dev/null
+++ b/src/features/manager/posting/ui/ApplicantCard.tsx
@@ -0,0 +1,62 @@
+import { ManagerApplicationStatusBadge } from '@/features/manager/posting/ui/ManagerApplicationStatusBadge'
+import { CalendarIcon, ClockIcon } from '@/features/manager/posting/ui/icons'
+import {
+ formatRelativeTime,
+ formatTimeRange,
+ formatWorkingDays,
+ type Application,
+} from '@/features/manager/posting/types/posting'
+
+interface ApplicantCardProps {
+ application: Application
+ onClick: () => void
+}
+
+/** 지원자 카드 — 업장·상태·이름·지원시각·희망 근무 (PDF ApplicantCard) */
+export function ApplicantCard({ application, onClick }: ApplicantCardProps) {
+ const { applicant, schedule } = application
+
+ return (
+
+ )
+}
diff --git a/src/features/manager/posting/ui/FilterBar.tsx b/src/features/manager/posting/ui/FilterBar.tsx
new file mode 100644
index 00000000..567f1b31
--- /dev/null
+++ b/src/features/manager/posting/ui/FilterBar.tsx
@@ -0,0 +1,52 @@
+import type { WorkspaceFilter } from '@/features/manager/posting/hooks/usePostingListViewModel'
+import { SelectDropdown } from '@/shared/ui/common/SelectDropdown'
+import type { SelectOption } from '@/shared/ui/common/SelectDropdown'
+
+interface FilterBarProps {
+ workspaceOptions: SelectOption[]
+ workspaceValue: WorkspaceFilter
+ onWorkspaceChange: (value: WorkspaceFilter) => void
+ statusOptions: readonly SelectOption[]
+ statusValue: TStatus
+ onStatusChange: (value: TStatus) => void
+ totalCount: number
+}
+
+/** 업장·상태 드롭다운 + 총 N건 */
+export function FilterBar({
+ workspaceOptions,
+ workspaceValue,
+ onWorkspaceChange,
+ statusOptions,
+ statusValue,
+ onStatusChange,
+ totalCount,
+}: FilterBarProps) {
+ return (
+
+
+
+
+
+
+ 총{' '}
+
+ {totalCount}
+
+ 건
+
+
+ )
+}
diff --git a/src/features/manager/posting/ui/HiringActionBar.tsx b/src/features/manager/posting/ui/HiringActionBar.tsx
new file mode 100644
index 00000000..380d0df4
--- /dev/null
+++ b/src/features/manager/posting/ui/HiringActionBar.tsx
@@ -0,0 +1,51 @@
+import {
+ HIRING_DECISIONS,
+ type HiringDecision,
+} from '@/features/manager/posting/lib/applicationStatus'
+import { LockIcon } from '@/features/manager/posting/ui/icons'
+import { cn } from '@/shared/lib/utils'
+
+interface HiringActionBarProps {
+ /** false면 '상태 변경 불가' 안내를 표시하고 액션을 숨깁니다 */
+ canDecide: boolean
+ onDecide: (decision: HiringDecision) => void
+}
+
+const DECISION_CLASS: Record = {
+ SHORTLISTED: 'border border-main bg-white text-main',
+ ACCEPTED: 'border border-main bg-main text-white',
+ REJECTED: 'border border-error bg-white text-error',
+}
+
+/** 채용 결정 하단 고정 바 — [서류합격][최종합격][불합격] */
+export function HiringActionBar({ canDecide, onDecide }: HiringActionBarProps) {
+ return (
+
+ {canDecide ? (
+
+ {HIRING_DECISIONS.map(decision => (
+
+ ))}
+
+ ) : (
+
+
+
+ 상태 변경 불가 (종료된 지원서)
+
+
+ )}
+
+ )
+}
diff --git a/src/features/manager/posting/ui/ManagerApplicationStatusBadge.tsx b/src/features/manager/posting/ui/ManagerApplicationStatusBadge.tsx
new file mode 100644
index 00000000..28df9128
--- /dev/null
+++ b/src/features/manager/posting/ui/ManagerApplicationStatusBadge.tsx
@@ -0,0 +1,29 @@
+import { resolveApplicationStatusBadge } from '@/features/manager/posting/lib/applicationStatus'
+import type { ApplicationStatus } from '@/features/manager/posting/types/posting'
+import { cn } from '@/shared/lib/utils'
+
+interface ManagerApplicationStatusBadgeProps {
+ status: ApplicationStatus
+ className?: string
+}
+
+/** 지원 상태 배지 — 지원완료 / 서류합격 / 최종합격 / 불합격 / 취소 / 만료 */
+export function ManagerApplicationStatusBadge({
+ status,
+ className,
+}: ManagerApplicationStatusBadgeProps) {
+ const style = resolveApplicationStatusBadge(status)
+
+ return (
+
+ {style.label}
+
+ )
+}
diff --git a/src/features/manager/posting/ui/ManagerPostingStatusBadge.tsx b/src/features/manager/posting/ui/ManagerPostingStatusBadge.tsx
new file mode 100644
index 00000000..dbe10240
--- /dev/null
+++ b/src/features/manager/posting/ui/ManagerPostingStatusBadge.tsx
@@ -0,0 +1,29 @@
+import { resolvePostingStatusBadge } from '@/features/manager/posting/lib/postingStatus'
+import type { PostingStatus } from '@/features/manager/posting/types/posting'
+import { cn } from '@/shared/lib/utils'
+
+interface ManagerPostingStatusBadgeProps {
+ status: PostingStatus
+ className?: string
+}
+
+/** 공고 상태 배지 — 모집중 / 모집완료 */
+export function ManagerPostingStatusBadge({
+ status,
+ className,
+}: ManagerPostingStatusBadgeProps) {
+ const style = resolvePostingStatusBadge(status)
+
+ return (
+
+ {style.label}
+
+ )
+}
diff --git a/src/features/manager/posting/ui/PostingFormFields.tsx b/src/features/manager/posting/ui/PostingFormFields.tsx
new file mode 100644
index 00000000..5a790c80
--- /dev/null
+++ b/src/features/manager/posting/ui/PostingFormFields.tsx
@@ -0,0 +1,197 @@
+import type { ReactNode } from 'react'
+
+import type { PostingFormViewModel } from '@/features/manager/posting/hooks/usePostingForm'
+import {
+ MOCK_KEYWORDS,
+ MOCK_WORKSPACES,
+} from '@/features/manager/posting/mocks/data'
+import { AlertIcon } from '@/features/manager/posting/ui/icons'
+import { ScheduleEditor } from '@/features/manager/posting/ui/ScheduleEditor'
+import {
+ MAX_KEYWORDS,
+ PAYMENT_TYPES,
+ PAYMENT_TYPE_LABEL,
+} from '@/features/manager/posting/types/posting'
+import { cn } from '@/shared/lib/utils'
+import { SelectDropdown } from '@/shared/ui/common/SelectDropdown'
+
+interface PostingFormFieldsProps {
+ form: PostingFormViewModel
+}
+
+/** 섹션 카드 — 라벨 + 필수 표시 + 에러 테두리/메시지 */
+function Section({
+ label,
+ required = false,
+ error,
+ hint,
+ children,
+}: {
+ label: string
+ required?: boolean
+ error?: string
+ hint?: ReactNode
+ children: ReactNode
+}) {
+ return (
+
+
+
+ {label}
+ {required ? * : null}
+
+ {hint}
+
+ {children}
+ {error ? (
+
+
+ {error}
+
+ ) : null}
+
+ )
+}
+
+const inputClassName =
+ 'h-12 w-full rounded-xl border bg-white px-3.5 typography-body02-regular text-text-100 placeholder:text-text-50 focus:outline-none'
+
+/** 공고 등록·수정 공용 폼 — 업장/제목/키워드/근무일정/급여/상세내용 */
+export function PostingFormFields({ form }: PostingFormFieldsProps) {
+ const { values, errors } = form
+
+ const workspaceOptions = MOCK_WORKSPACES.map(workspace => ({
+ value: workspace.id,
+ label: workspace.businessName,
+ }))
+
+ return (
+
+
+
+
+
+
+ 최대 {MAX_KEYWORDS}개
+
+ }
+ >
+
+ {MOCK_KEYWORDS.map(keyword => {
+ const isSelected = values.keywords.includes(keyword)
+ const isDisabled =
+ !isSelected && values.keywords.length >= MAX_KEYWORDS
+ return (
+
+ )
+ })}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/features/manager/posting/ui/PostingListCard.tsx b/src/features/manager/posting/ui/PostingListCard.tsx
new file mode 100644
index 00000000..9cb287ec
--- /dev/null
+++ b/src/features/manager/posting/ui/PostingListCard.tsx
@@ -0,0 +1,71 @@
+import { ManagerPostingStatusBadge } from '@/features/manager/posting/ui/ManagerPostingStatusBadge'
+import { CalendarIcon, ClockIcon } from '@/features/manager/posting/ui/icons'
+import {
+ formatTimeRange,
+ formatWorkingDays,
+ PAYMENT_TYPE_LABEL,
+ type Posting,
+} from '@/features/manager/posting/types/posting'
+import { cn } from '@/shared/lib/utils'
+
+interface PostingListCardProps {
+ posting: Posting
+ onClick: () => void
+}
+
+/** 내 공고 카드 — PDF OngoingPostingCard */
+export function PostingListCard({ posting, onClick }: PostingListCardProps) {
+ const isClosed = posting.status === 'CLOSED'
+ const firstSchedule = posting.schedules[0]
+
+ return (
+
+ )
+}
diff --git a/src/features/manager/posting/ui/ScheduleEditor.tsx b/src/features/manager/posting/ui/ScheduleEditor.tsx
new file mode 100644
index 00000000..e5407146
--- /dev/null
+++ b/src/features/manager/posting/ui/ScheduleEditor.tsx
@@ -0,0 +1,177 @@
+import type { PostingFormViewModel } from '@/features/manager/posting/hooks/usePostingForm'
+import { CloseIcon, PlusIcon } from '@/features/manager/posting/ui/icons'
+import {
+ WORKING_DAYS,
+ WORKING_DAY_LABEL,
+ type PostingFormSchedule,
+} from '@/features/manager/posting/types/posting'
+import { cn } from '@/shared/lib/utils'
+
+interface ScheduleEditorProps {
+ form: PostingFormViewModel
+}
+
+function ScheduleCard({
+ schedule,
+ index,
+ form,
+ canRemove,
+}: {
+ schedule: PostingFormSchedule
+ index: number
+ form: PostingFormViewModel
+ canRemove: boolean
+}) {
+ const isExisting = schedule.id !== null
+
+ return (
+
+
+
+
+ 일정 {index + 1}
+
+ {/* 수정 화면에서 기존 일정과 신규 추가분을 구분 */}
+ {form.isEditMode ? (
+
+ {isExisting ? '기존' : '신규'}
+
+ ) : null}
+
+ {canRemove ? (
+
+ ) : null}
+
+
+
근무요일
+
+ {WORKING_DAYS.map(day => {
+ const isSelected = schedule.workingDays.includes(day)
+ return (
+
+ )
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/** 근무일정 편집기 — 일정 카드 추가/삭제, 요일 토글, 시간·포지션·인원 입력 */
+export function ScheduleEditor({ form }: ScheduleEditorProps) {
+ const { schedules } = form.values
+
+ return (
+
+ {schedules.map((schedule, index) => (
+
1}
+ />
+ ))}
+
+
+
+ )
+}
diff --git a/src/features/manager/posting/ui/icons.tsx b/src/features/manager/posting/ui/icons.tsx
new file mode 100644
index 00000000..574cabb2
--- /dev/null
+++ b/src/features/manager/posting/ui/icons.tsx
@@ -0,0 +1,164 @@
+/** 구인구직 화면 전용 아이콘 — 24 viewBox, 색상은 부모의 text-* 를 따릅니다 */
+
+interface IconProps {
+ className?: string
+}
+
+export function CalendarIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function ClockIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function PersonIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function ChevronRightIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function PlusIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function CloseIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function AlertIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function CheckCircleIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function LockIcon({ className }: IconProps) {
+ return (
+
+ )
+}
From ffe76569738b322f7eec56269308dd53d47e720e Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Sun, 26 Jul 2026 20:30:37 +0900
Subject: [PATCH 04/33] =?UTF-8?q?feat:=20=EC=82=AC=EC=9E=A5=EB=8B=98=20?=
=?UTF-8?q?=EA=B5=AC=EC=9D=B8=EA=B5=AC=EC=A7=81=20=ED=99=94=EB=A9=B4=206?=
=?UTF-8?q?=EC=A2=85=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EB=9D=BC=EC=9A=B0?=
=?UTF-8?q?=ED=8A=B8=20=EB=93=B1=EB=A1=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/App.tsx | 57 ++++++
.../manager/application-detail/index.tsx | 186 +++++++++++++++++
src/pages/manager/application-list/index.tsx | 112 +++++++++++
src/pages/manager/posting-create/index.tsx | 63 ++++++
src/pages/manager/posting-detail/index.tsx | 187 ++++++++++++++++++
src/pages/manager/posting-edit/index.tsx | 88 +++++++++
src/pages/manager/posting-list/index.tsx | 98 +++++++++
7 files changed, 791 insertions(+)
create mode 100644 src/pages/manager/application-detail/index.tsx
create mode 100644 src/pages/manager/application-list/index.tsx
create mode 100644 src/pages/manager/posting-create/index.tsx
create mode 100644 src/pages/manager/posting-detail/index.tsx
create mode 100644 src/pages/manager/posting-edit/index.tsx
create mode 100644 src/pages/manager/posting-list/index.tsx
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 20665f50..5b29df49 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -30,6 +30,12 @@ import { StoreRegisterRequestsPage } from '@/pages/store-register/requests'
import { StoreRegisterRequestDetailPage } from '@/pages/store-register/request-detail'
import { ManagerWorkerInvitePage } from '@/pages/manager/worker-invite'
import { WorkspaceImageEditPage } from '@/pages/manager/workspace-image-edit'
+import { ManagerPostingListPage } from '@/pages/manager/posting-list'
+import { ManagerPostingCreatePage } from '@/pages/manager/posting-create'
+import { ManagerPostingDetailPage } from '@/pages/manager/posting-detail'
+import { ManagerPostingEditPage } from '@/pages/manager/posting-edit'
+import { ManagerApplicationListPage } from '@/pages/manager/application-list'
+import { ManagerApplicationDetailPage } from '@/pages/manager/application-detail'
import { WorkspaceJoinPage } from '@/pages/user/workspace-join'
import { NotificationPage } from '@/pages/notification'
import { NotificationSettingsPage } from '@/pages/notification/settings'
@@ -44,6 +50,7 @@ import { ErrorPageRoute } from '@/pages/error'
import { MobileLayout } from '@/shared/ui/MobileLayout'
import { MobileLayoutWithDocbar } from '@/shared/ui/MobileLayoutWithDocbar'
import { HomeRouteGuard } from '@/shared/ui/common/HomeRouteGuard'
+import { ToastViewport } from '@/shared/ui/common/ToastViewport'
import { ROUTES } from '@/shared/constants/routes'
const SignupPage = lazy(async () => {
@@ -161,6 +168,39 @@ export function App() {
path={ROUTES.MANAGER.WORKSPACE_IMAGES_EDIT_PATTERN}
element={}
/>
+ {/* 사장님 구인구직 — 정적 세그먼트가 :postingId보다 우선 매칭됩니다 */}
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
}>
@@ -205,6 +245,22 @@ export function App() {
path={ROUTES.MANAGER.SOCIAL_CHAT}
element={}
/>
+
+
+
+ }
+ />
+
+
+
+ }
+ />
} />
} />
} />
+
)
}
diff --git a/src/pages/manager/application-detail/index.tsx b/src/pages/manager/application-detail/index.tsx
new file mode 100644
index 00000000..5f1ff0dd
--- /dev/null
+++ b/src/pages/manager/application-detail/index.tsx
@@ -0,0 +1,186 @@
+import { useParams } from 'react-router-dom'
+
+import { useApplicationDetailViewModel } from '@/features/manager/posting/hooks/useApplicationDetailViewModel'
+import { HiringActionBar } from '@/features/manager/posting/ui/HiringActionBar'
+import { ManagerApplicationStatusBadge } from '@/features/manager/posting/ui/ManagerApplicationStatusBadge'
+import { CheckCircleIcon } from '@/features/manager/posting/ui/icons'
+import {
+ formatTimeRange,
+ formatWorkingDays,
+ type ApplicationStatus,
+} from '@/features/manager/posting/types/posting'
+import { resolveApplicationStatusBadge } from '@/features/manager/posting/lib/applicationStatus'
+import { ConfirmModal } from '@/shared/ui/common/ConfirmModal'
+import { Navbar } from '@/shared/ui/common/Navbar'
+
+function InfoRow({
+ label,
+ value,
+ isLast = false,
+}: {
+ label: string
+ value: string
+ isLast?: boolean
+}) {
+ return (
+
+ {label}
+ {value}
+
+ )
+}
+
+/** 종료된 지원서 안내 문구 */
+function TerminalNotice({ status }: { status: ApplicationStatus }) {
+ const { label } = resolveApplicationStatusBadge(status)
+ const isAccepted = status === 'ACCEPTED'
+
+ return (
+
+
+
+ 이미 {label}{' '}
+ {isAccepted
+ ? '처리되어 근무자로 등록됐어요. 상태를 더 이상 변경할 수 없어요.'
+ : '처리된 지원서예요. 상태를 더 이상 변경할 수 없어요.'}
+
+
+ )
+}
+
+/** SCREEN 3 · 지원자 상세 · 채용 결정 */
+export function ManagerApplicationDetailPage() {
+ const { applicationId } = useParams()
+ const numericApplicationId = Number(applicationId)
+
+ const {
+ application,
+ isNotFound,
+ canDecide,
+ pendingDecision,
+ decisionCopy,
+ requestDecision,
+ cancelDecision,
+ confirmDecision,
+ } = useApplicationDetailViewModel(numericApplicationId)
+
+ if (isNotFound || !application) {
+ return (
+
+
+
+
+ 지원서를 찾을 수 없어요.
+
+
+
+ )
+ }
+
+ const { applicant, schedule } = application
+
+ return (
+
+
+
+
+
+
+ {applicant.name.charAt(0)}
+
+
+
+
+ {applicant.name}
+
+
+
+
+ {application.workspaceName} ·{' '}
+ {formatWorkingDays(schedule.workingDays)}{' '}
+ {formatTimeRange(schedule.startTime, schedule.endTime)}
+ {schedule.position ? ` · ${schedule.position}` : ''}
+
+
+
+
+ {!canDecide ? : null}
+
+
+
+ {applicant.certificates.length > 0 ? (
+
+
+ 보유 자격증
+
+
+ {applicant.certificates.map((certificate, index) => (
+ -
+
+
+ {certificate.name}
+
+
+ {certificate.issuer}
+
+
+
+ {certificate.acquiredAt}
+
+
+ ))}
+
+
+ ) : null}
+
+
+
+ 지원 메시지
+
+
+ {application.description}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/manager/application-list/index.tsx b/src/pages/manager/application-list/index.tsx
new file mode 100644
index 00000000..fce790a0
--- /dev/null
+++ b/src/pages/manager/application-list/index.tsx
@@ -0,0 +1,112 @@
+import { useNavigate, useSearchParams } from 'react-router-dom'
+
+import { useApplicationListViewModel } from '@/features/manager/posting/hooks/useApplicationListViewModel'
+import { APPLICATION_STATUS_FILTER_OPTIONS } from '@/features/manager/posting/lib/applicationStatus'
+import { ApplicantCard } from '@/features/manager/posting/ui/ApplicantCard'
+import { FilterBar } from '@/features/manager/posting/ui/FilterBar'
+import { PersonIcon } from '@/features/manager/posting/ui/icons'
+import { managerPostingApplicationDetailPath } from '@/shared/constants/routes'
+import { MoreButton } from '@/shared/ui/common/MoreButton'
+import { Navbar } from '@/shared/ui/common/Navbar'
+import { Skeleton } from '@/shared/ui/common/Skeleton'
+
+function ApplicantListSkeleton() {
+ return (
+
+ {[0, 1, 2].map(index => (
+ -
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )
+}
+
+/** SCREEN 2 · 지원자 목록 */
+export function ManagerApplicationListPage() {
+ const navigate = useNavigate()
+ const [searchParams] = useSearchParams()
+
+ // 공고 상세 → '지원자 보기'로 진입한 경우 해당 공고로 한정
+ const postingIdParam = searchParams.get('postingId')
+ const postingId = postingIdParam ? Number(postingIdParam) : undefined
+
+ const {
+ applications,
+ totalCount,
+ isLoading,
+ isEmpty,
+ hasNextPage,
+ fetchNextPage,
+ workspaceFilter,
+ setWorkspaceFilter,
+ statusFilter,
+ setStatusFilter,
+ workspaceOptions,
+ } = useApplicationListViewModel({ postingId })
+
+ return (
+
+
+
+ {/* 하단 Docbar(h-14)에 가리지 않도록 여유 패딩 */}
+
+
+
+ {isLoading ? : null}
+
+ {isEmpty ? (
+
+
+
+
+
+ 조건에 맞는 지원자가 없어요
+
+
+ 필터를 바꾸거나 공고를 새로 등록해 지원자를
+
+ 기다려 보세요.
+
+
+ ) : null}
+
+ {!isLoading && applications.length > 0 ? (
+ <>
+
+ {applications.map(application => (
+ -
+
+ navigate(
+ managerPostingApplicationDetailPath(application.id)
+ )
+ }
+ />
+
+ ))}
+
+ {hasNextPage ? : null}
+ >
+ ) : null}
+
+
+ )
+}
diff --git a/src/pages/manager/posting-create/index.tsx b/src/pages/manager/posting-create/index.tsx
new file mode 100644
index 00000000..bfbe64d4
--- /dev/null
+++ b/src/pages/manager/posting-create/index.tsx
@@ -0,0 +1,63 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+
+import { usePostingForm } from '@/features/manager/posting/hooks/usePostingForm'
+import { MOCK_WORKSPACES } from '@/features/manager/posting/mocks/data'
+import { useMockPostingStore } from '@/features/manager/posting/mocks/store'
+import { PostingFormFields } from '@/features/manager/posting/ui/PostingFormFields'
+import { ROUTES } from '@/shared/constants/routes'
+import { showToast } from '@/shared/stores/useToastStore'
+import { AuthButton } from '@/shared/ui/common/AuthButton'
+import { ConfirmModal } from '@/shared/ui/common/ConfirmModal'
+import { Navbar } from '@/shared/ui/common/Navbar'
+
+/** SCREEN 1 · 공고 등록 */
+export function ManagerPostingCreatePage() {
+ const navigate = useNavigate()
+ const form = usePostingForm()
+ const createPosting = useMockPostingStore(state => state.createPosting)
+ const [isConfirmOpen, setIsConfirmOpen] = useState(false)
+
+ const handleSubmit = () => {
+ if (!form.attemptSubmit()) return
+ setIsConfirmOpen(true)
+ }
+
+ const handleConfirm = () => {
+ const workspaceName =
+ MOCK_WORKSPACES.find(
+ workspace => workspace.id === form.values.workspaceId
+ )?.businessName ?? ''
+
+ createPosting(form.values, workspaceName)
+ setIsConfirmOpen(false)
+ showToast('공고를 등록했어요')
+ navigate(ROUTES.MANAGER.POSTINGS, { replace: true })
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
setIsConfirmOpen(false)}
+ />
+
+ )
+}
diff --git a/src/pages/manager/posting-detail/index.tsx b/src/pages/manager/posting-detail/index.tsx
new file mode 100644
index 00000000..e843bf64
--- /dev/null
+++ b/src/pages/manager/posting-detail/index.tsx
@@ -0,0 +1,187 @@
+import { useNavigate, useParams } from 'react-router-dom'
+
+import { usePostingDetailViewModel } from '@/features/manager/posting/hooks/usePostingDetailViewModel'
+import { ManagerPostingStatusBadge } from '@/features/manager/posting/ui/ManagerPostingStatusBadge'
+import {
+ CalendarIcon,
+ ChevronRightIcon,
+ ClockIcon,
+ PersonIcon,
+} from '@/features/manager/posting/ui/icons'
+import {
+ formatTimeRange,
+ formatWorkingDays,
+ PAYMENT_TYPE_LABEL,
+} from '@/features/manager/posting/types/posting'
+import {
+ managerPostingApplicationsPath,
+ managerPostingEditPath,
+} from '@/shared/constants/routes'
+import { ConfirmModal } from '@/shared/ui/common/ConfirmModal'
+import { Navbar } from '@/shared/ui/common/Navbar'
+
+/** SCREEN 4 · 내 공고 상세 · 마감 */
+export function ManagerPostingDetailPage() {
+ const navigate = useNavigate()
+ const { postingId } = useParams()
+ const numericPostingId = Number(postingId)
+
+ const {
+ posting,
+ applicantCount,
+ isNotFound,
+ isCloseModalOpen,
+ openCloseModal,
+ closeCloseModal,
+ confirmClose,
+ } = usePostingDetailViewModel(numericPostingId)
+
+ if (isNotFound || !posting) {
+ return (
+
+
+
+
+ 공고를 찾을 수 없어요.
+
+
+
+ )
+ }
+
+ const isClosed = posting.status === 'CLOSED'
+
+ return (
+
+
+
+
+
+
+ {posting.title}
+
+
+
+ {posting.workspaceName} · {posting.businessType}
+
+
+
+
+ {posting.keywords.length > 0 ? (
+
+ {posting.keywords.map(keyword => (
+
+ {keyword}
+
+ ))}
+
+ ) : null}
+
+
+
+ 급여
+
+ {PAYMENT_TYPE_LABEL[posting.paymentType]}{' '}
+ {posting.payAmount.toLocaleString('ko-KR')}원
+
+
+
+
+
+ 근무일정
+
+
+ {posting.schedules.map((schedule, index) => (
+ -
+
+
+ {schedule.position || '포지션 미지정'}
+
+
+ 모집 {schedule.positionsNeeded}명
+
+
+
+
+
+ {formatWorkingDays(schedule.workingDays)}
+
+
+
+ {formatTimeRange(schedule.startTime, schedule.endTime)}
+
+
+
+ ))}
+
+
+
+ {posting.description ? (
+
+
+ 상세내용
+
+
+ {posting.description}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/manager/posting-edit/index.tsx b/src/pages/manager/posting-edit/index.tsx
new file mode 100644
index 00000000..eb164c08
--- /dev/null
+++ b/src/pages/manager/posting-edit/index.tsx
@@ -0,0 +1,88 @@
+import { useNavigate, useParams } from 'react-router-dom'
+
+import { usePostingForm } from '@/features/manager/posting/hooks/usePostingForm'
+import { useMockPostingStore } from '@/features/manager/posting/mocks/store'
+import { PostingFormFields } from '@/features/manager/posting/ui/PostingFormFields'
+import type {
+ Posting,
+ PostingFormValues,
+} from '@/features/manager/posting/types/posting'
+import { showToast } from '@/shared/stores/useToastStore'
+import { AuthButton } from '@/shared/ui/common/AuthButton'
+import { Navbar } from '@/shared/ui/common/Navbar'
+
+/** SCREEN 4 · 공고 수정 — 등록 폼 재사용 · 프리필 */
+export function ManagerPostingEditPage() {
+ const navigate = useNavigate()
+ const { postingId } = useParams()
+ const numericPostingId = Number(postingId)
+
+ const posting = useMockPostingStore(state =>
+ state.postings.find(item => item.id === numericPostingId)
+ )
+ const updatePosting = useMockPostingStore(state => state.updatePosting)
+
+ if (!posting) {
+ return (
+
+
+
+
+ 공고를 찾을 수 없어요.
+
+
+
+ )
+ }
+
+ return (
+ {
+ updatePosting(numericPostingId, values)
+ showToast('공고를 수정했어요')
+ navigate(-1)
+ }}
+ />
+ )
+}
+
+interface PostingEditContentProps {
+ postingId: number
+ posting: Posting
+ onSubmit: (values: PostingFormValues) => void
+}
+
+/**
+ * 폼 초기값이 `posting`에 의존하므로, 공고를 찾은 뒤에 마운트되도록 분리합니다
+ * (훅 순서를 안정적으로 유지).
+ */
+function PostingEditContent({
+ postingId,
+ posting,
+ onSubmit,
+}: PostingEditContentProps) {
+ const form = usePostingForm({ posting })
+
+ const handleSubmit = () => {
+ if (!form.attemptSubmit()) return
+ onSubmit(form.values)
+ }
+
+ return (
+
+ )
+}
diff --git a/src/pages/manager/posting-list/index.tsx b/src/pages/manager/posting-list/index.tsx
new file mode 100644
index 00000000..291d8cff
--- /dev/null
+++ b/src/pages/manager/posting-list/index.tsx
@@ -0,0 +1,98 @@
+import { useNavigate } from 'react-router-dom'
+
+import { usePostingListViewModel } from '@/features/manager/posting/hooks/usePostingListViewModel'
+import { POSTING_STATUS_FILTER_OPTIONS } from '@/features/manager/posting/lib/postingStatus'
+import { FilterBar } from '@/features/manager/posting/ui/FilterBar'
+import { PostingListCard } from '@/features/manager/posting/ui/PostingListCard'
+import { managerPostingDetailPath, ROUTES } from '@/shared/constants/routes'
+import { AuthButton } from '@/shared/ui/common/AuthButton'
+import { Navbar } from '@/shared/ui/common/Navbar'
+import { Skeleton } from '@/shared/ui/common/Skeleton'
+
+function PostingListSkeleton() {
+ return (
+
+ {[0, 1, 2].map(index => (
+ -
+
+
+
+
+
+
+
+
+ ))}
+
+ )
+}
+
+/** SCREEN 5 · 내 공고 목록 — 사장님 알바찾기(내 공고) 탭 진입점 */
+export function ManagerPostingListPage() {
+ const navigate = useNavigate()
+ const {
+ postings,
+ totalCount,
+ isLoading,
+ isEmpty,
+ workspaceFilter,
+ setWorkspaceFilter,
+ statusFilter,
+ setStatusFilter,
+ workspaceOptions,
+ } = usePostingListViewModel()
+
+ return (
+
+
navigate(-1)}
+ />
+
+
+
+
+ {isLoading ? : null}
+
+ {isEmpty ? (
+
+
+ 등록된 공고가 없어요
+
+
+ 필터를 바꾸거나 새 공고를 등록해 보세요.
+
+
+ ) : null}
+
+ {!isLoading && postings.length > 0 ? (
+
+ {postings.map(posting => (
+ -
+ navigate(managerPostingDetailPath(posting.id))}
+ />
+
+ ))}
+
+ ) : null}
+
+
+
+
navigate(ROUTES.MANAGER.POSTING_NEW)}>
+ 공고 작성
+
+
+
+ )
+}
From f519133eee4ee60fef9834facc9225e2a1b71085 Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Sun, 26 Jul 2026 20:30:39 +0900
Subject: [PATCH 05/33] =?UTF-8?q?feat:=20Docbar=20=EC=82=AC=EC=9E=A5?=
=?UTF-8?q?=EB=8B=98=20=EC=A0=84=EC=9A=A9=20=EB=82=B4=20=EA=B3=B5=EA=B3=A0?=
=?UTF-8?q?=C2=B7=EC=A7=80=EC=9B=90=EC=9E=90=20=ED=83=AD=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/assets/icons/doc/Applicant.svg | 6 ++++
src/shared/stores/useDocStore.ts | 14 ++++----
src/shared/types/tab.ts | 4 ++-
src/shared/ui/common/Docbar.tsx | 54 ++++++++++++++++++++++------
storybook/stories/Docbar.stories.tsx | 29 +++++++++++++++
5 files changed, 88 insertions(+), 19 deletions(-)
create mode 100644 src/assets/icons/doc/Applicant.svg
diff --git a/src/assets/icons/doc/Applicant.svg b/src/assets/icons/doc/Applicant.svg
new file mode 100644
index 00000000..a2241ee1
--- /dev/null
+++ b/src/assets/icons/doc/Applicant.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/shared/stores/useDocStore.ts b/src/shared/stores/useDocStore.ts
index 9ce61b87..a38ea0f8 100644
--- a/src/shared/stores/useDocStore.ts
+++ b/src/shared/stores/useDocStore.ts
@@ -8,22 +8,22 @@ const PATHNAME_TAB_MAP: Array<{ matcher: RegExp; tab: TabKey }> = [
{ matcher: /^\/user\/substitute-request/, tab: 'substitute' },
{ matcher: /^\/manager\/home/, tab: 'home' },
{ matcher: /^\/user\/job-lookup-map/, tab: 'search' },
+ // 사장님 구인구직 — 지원자 경로가 아래 내 공고 패턴보다 먼저 매칭돼야 합니다
+ { matcher: /^\/manager\/postings\/applications/, tab: 'applicant' },
+ // 사장님 구인구직 — 알바찾기(내 공고) 탭 하이라이트
+ { matcher: /^\/manager\/postings/, tab: 'search' },
]
-const createSelectedTab = (activeTab?: TabKey) => ({
+const createSelectedTab = (activeTab?: TabKey): Record => ({
home: activeTab === 'home',
my: activeTab === 'my',
search: activeTab === 'search',
substitute: activeTab === 'substitute',
+ applicant: activeTab === 'applicant',
})
interface DocStoreState {
- selectedTab: {
- home: boolean
- my: boolean
- search: boolean
- substitute: boolean
- }
+ selectedTab: Record
setSelectedTab: (selectedTab: DocStoreState['selectedTab']) => void
setSelectedTabByPathname: (pathname: string) => void
}
diff --git a/src/shared/types/tab.ts b/src/shared/types/tab.ts
index 4413f25c..24ea3ce8 100644
--- a/src/shared/types/tab.ts
+++ b/src/shared/types/tab.ts
@@ -1,8 +1,10 @@
-export type TabKey = 'home' | 'my' | 'search' | 'substitute'
+export type TabKey = 'home' | 'my' | 'search' | 'substitute' | 'applicant'
export const TAB_TITLE_MAP: Record = {
home: '홈',
my: 'MY',
search: '알바 찾기',
substitute: '대타',
+ /** 사장님(MANAGER) 전용 탭 */
+ applicant: '지원자',
}
diff --git a/src/shared/ui/common/Docbar.tsx b/src/shared/ui/common/Docbar.tsx
index e4ddc0c9..08ea6576 100644
--- a/src/shared/ui/common/Docbar.tsx
+++ b/src/shared/ui/common/Docbar.tsx
@@ -1,3 +1,4 @@
+import ApplicantIcon from '@/assets/icons/doc/Applicant.svg?react'
import HomeIcon from '@/assets/icons/doc/Home.svg?react'
import MYIcon from '@/assets/icons/doc/MY.svg?react'
import SearchIcon from '@/assets/icons/doc/Search.svg?react'
@@ -17,12 +18,14 @@ function DocContent({
alt,
isSelected,
titleKey,
+ label,
onClick,
}: {
icon: ComponentType>
alt: string
isSelected: boolean
titleKey: TabKey
+ label?: string
onClick: () => void
}) {
const Icon = icon
@@ -46,7 +49,7 @@ function DocContent({
letterSpacing: typography.doc.letterSpacing,
}}
>
- {TAB_TITLE_MAP[titleKey]}
+ {label ?? TAB_TITLE_MAP[titleKey]}
)
@@ -58,12 +61,20 @@ interface DocbarViewProps {
selectedTab: DocbarSelectedTab
onTabClick: (tab: TabKey) => void
tabs: TabKey[]
+ /** 탭별 라벨 오버라이드 — 미지정 탭은 TAB_TITLE_MAP 기본값을 사용 */
+ labelByTab?: Partial>
}
-export function DocbarView({ selectedTab, onTabClick, tabs }: DocbarViewProps) {
+export function DocbarView({
+ selectedTab,
+ onTabClick,
+ tabs,
+ labelByTab,
+}: DocbarViewProps) {
const iconByTab: Record>> = {
home: HomeIcon,
search: SearchIcon,
+ applicant: ApplicantIcon,
substitute: SubstituteIcon,
my: MYIcon,
}
@@ -71,6 +82,7 @@ export function DocbarView({ selectedTab, onTabClick, tabs }: DocbarViewProps) {
const altByTab: Record = {
home: 'Home',
search: 'Search',
+ applicant: 'Applicant',
substitute: 'Substitute',
my: 'MY',
}
@@ -85,6 +97,7 @@ export function DocbarView({ selectedTab, onTabClick, tabs }: DocbarViewProps) {
alt={altByTab[tab]}
isSelected={selectedTab[tab]}
titleKey={tab}
+ label={labelByTab?.[tab]}
onClick={() => onTabClick(tab)}
/>
))}
@@ -106,22 +119,36 @@ export function Docbar() {
setSelectedTabByPathname(pathname)
}, [pathname, setSelectedTabByPathname])
+ const isManager = scope === 'MANAGER'
+
+ /** 지원자 탭은 사장님 전용 — 일반 유저는 기존 4탭을 유지합니다 */
const tabs = useMemo(
- () => ['home', 'search', 'substitute', 'my'],
- []
+ () =>
+ isManager
+ ? ['home', 'search', 'applicant', 'substitute', 'my']
+ : ['home', 'search', 'substitute', 'my'],
+ [isManager]
)
const pathByTab: Record = useMemo(
() => ({
home: homePathForScope(scope),
- search: ROUTES.USER.JOB_LOOKUP_MAP,
- substitute:
- scope === 'MANAGER'
- ? ROUTES.MANAGER.SUBSTITUTE_REQUEST
- : ROUTES.USER.SUBSTITUTE_REQUEST,
+ // 사장님은 구인구직(내 공고 목록), 일반 유저는 기존 알바찾기 경로 유지
+ search: isManager ? ROUTES.MANAGER.POSTINGS : ROUTES.USER.JOB_LOOKUP_MAP,
+ // 사장님 전용 탭 — 일반 유저에게는 렌더링되지 않습니다
+ applicant: ROUTES.MANAGER.POSTING_APPLICATIONS,
+ substitute: isManager
+ ? ROUTES.MANAGER.SUBSTITUTE_REQUEST
+ : ROUTES.USER.SUBSTITUTE_REQUEST,
my: ROUTES.MY.ROOT,
}),
- [scope]
+ [scope, isManager]
+ )
+
+ /** 사장님에게는 '알바 찾기' 대신 '내 공고'로 노출 */
+ const labelByTab = useMemo> | undefined>(
+ () => (isManager ? { search: '내 공고' } : undefined),
+ [isManager]
)
const onTabClick = (tab: TabKey) => {
@@ -129,6 +156,11 @@ export function Docbar() {
}
return (
-
+
)
}
diff --git a/storybook/stories/Docbar.stories.tsx b/storybook/stories/Docbar.stories.tsx
index ebb070a0..35f3c844 100644
--- a/storybook/stories/Docbar.stories.tsx
+++ b/storybook/stories/Docbar.stories.tsx
@@ -11,8 +11,18 @@ const createSelectedTab = (activeTab: TabKey): DocbarSelectedTab => ({
my: activeTab === 'my',
search: activeTab === 'search',
substitute: activeTab === 'substitute',
+ applicant: activeTab === 'applicant',
})
+/** 사장님(MANAGER) 탭 구성 — 지원자 탭 포함 5탭 */
+const MANAGER_TABS: TabKey[] = [
+ 'home',
+ 'search',
+ 'applicant',
+ 'substitute',
+ 'my',
+]
+
const meta = {
title: 'shared/ui/common/Docbar',
component: DocbarView,
@@ -56,3 +66,22 @@ export const MySelected: Story = {
tabs: ['home', 'search', 'substitute', 'my'],
},
}
+
+/** 사장님 5탭 — '알바 찾기'가 '내 공고'로 노출되고 지원자 탭이 추가됩니다 */
+export const ManagerApplicantSelected: Story = {
+ args: {
+ selectedTab: createSelectedTab('applicant'),
+ onTabClick: () => {},
+ tabs: MANAGER_TABS,
+ labelByTab: { search: '내 공고' },
+ },
+}
+
+export const ManagerPostingsSelected: Story = {
+ args: {
+ selectedTab: createSelectedTab('search'),
+ onTabClick: () => {},
+ tabs: MANAGER_TABS,
+ labelByTab: { search: '내 공고' },
+ },
+}
From 05091cf6cfefc23c969a484579219129e984f9fb Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Sun, 26 Jul 2026 20:53:26 +0900
Subject: [PATCH 06/33] =?UTF-8?q?refactor:=20=EA=B5=AC=EC=9D=B8=EA=B5=AC?=
=?UTF-8?q?=EC=A7=81=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20=EC=95=84=EC=9D=B4?=
=?UTF-8?q?=EC=BD=98=20SVG=20=EC=9E=90=EC=82=B0=EC=9C=BC=EB=A1=9C=20?=
=?UTF-8?q?=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/assets/icons/posting/Alert.svg | 4 +
src/assets/icons/posting/Calendar.svg | 4 +
src/assets/icons/posting/CheckCircle.svg | 4 +
src/assets/icons/posting/ChevronRight.svg | 3 +
src/assets/icons/posting/Clock.svg | 4 +
src/assets/icons/posting/Close.svg | 3 +
src/assets/icons/posting/Lock.svg | 4 +
src/assets/icons/posting/Person.svg | 4 +
src/assets/icons/posting/Plus.svg | 3 +
.../manager/posting/ui/ApplicantCard.tsx | 3 +-
.../manager/posting/ui/HiringActionBar.tsx | 2 +-
.../manager/posting/ui/PostingFormFields.tsx | 2 +-
.../manager/posting/ui/PostingListCard.tsx | 3 +-
.../manager/posting/ui/ScheduleEditor.tsx | 3 +-
src/features/manager/posting/ui/icons.tsx | 164 ------------------
.../manager/application-detail/index.tsx | 2 +-
src/pages/manager/application-list/index.tsx | 2 +-
src/pages/manager/posting-detail/index.tsx | 10 +-
18 files changed, 47 insertions(+), 177 deletions(-)
create mode 100644 src/assets/icons/posting/Alert.svg
create mode 100644 src/assets/icons/posting/Calendar.svg
create mode 100644 src/assets/icons/posting/CheckCircle.svg
create mode 100644 src/assets/icons/posting/ChevronRight.svg
create mode 100644 src/assets/icons/posting/Clock.svg
create mode 100644 src/assets/icons/posting/Close.svg
create mode 100644 src/assets/icons/posting/Lock.svg
create mode 100644 src/assets/icons/posting/Person.svg
create mode 100644 src/assets/icons/posting/Plus.svg
delete mode 100644 src/features/manager/posting/ui/icons.tsx
diff --git a/src/assets/icons/posting/Alert.svg b/src/assets/icons/posting/Alert.svg
new file mode 100644
index 00000000..c221a140
--- /dev/null
+++ b/src/assets/icons/posting/Alert.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/icons/posting/Calendar.svg b/src/assets/icons/posting/Calendar.svg
new file mode 100644
index 00000000..17c0f11b
--- /dev/null
+++ b/src/assets/icons/posting/Calendar.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/icons/posting/CheckCircle.svg b/src/assets/icons/posting/CheckCircle.svg
new file mode 100644
index 00000000..b75691d9
--- /dev/null
+++ b/src/assets/icons/posting/CheckCircle.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/icons/posting/ChevronRight.svg b/src/assets/icons/posting/ChevronRight.svg
new file mode 100644
index 00000000..55696990
--- /dev/null
+++ b/src/assets/icons/posting/ChevronRight.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/icons/posting/Clock.svg b/src/assets/icons/posting/Clock.svg
new file mode 100644
index 00000000..6bad8fda
--- /dev/null
+++ b/src/assets/icons/posting/Clock.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/icons/posting/Close.svg b/src/assets/icons/posting/Close.svg
new file mode 100644
index 00000000..fa406d71
--- /dev/null
+++ b/src/assets/icons/posting/Close.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/icons/posting/Lock.svg b/src/assets/icons/posting/Lock.svg
new file mode 100644
index 00000000..c929a9c8
--- /dev/null
+++ b/src/assets/icons/posting/Lock.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/icons/posting/Person.svg b/src/assets/icons/posting/Person.svg
new file mode 100644
index 00000000..057cddce
--- /dev/null
+++ b/src/assets/icons/posting/Person.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/icons/posting/Plus.svg b/src/assets/icons/posting/Plus.svg
new file mode 100644
index 00000000..fca5177b
--- /dev/null
+++ b/src/assets/icons/posting/Plus.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/features/manager/posting/ui/ApplicantCard.tsx b/src/features/manager/posting/ui/ApplicantCard.tsx
index c189969a..3aa8eece 100644
--- a/src/features/manager/posting/ui/ApplicantCard.tsx
+++ b/src/features/manager/posting/ui/ApplicantCard.tsx
@@ -1,5 +1,6 @@
+import CalendarIcon from '@/assets/icons/posting/Calendar.svg?react'
+import ClockIcon from '@/assets/icons/posting/Clock.svg?react'
import { ManagerApplicationStatusBadge } from '@/features/manager/posting/ui/ManagerApplicationStatusBadge'
-import { CalendarIcon, ClockIcon } from '@/features/manager/posting/ui/icons'
import {
formatRelativeTime,
formatTimeRange,
diff --git a/src/features/manager/posting/ui/HiringActionBar.tsx b/src/features/manager/posting/ui/HiringActionBar.tsx
index 380d0df4..e4368f78 100644
--- a/src/features/manager/posting/ui/HiringActionBar.tsx
+++ b/src/features/manager/posting/ui/HiringActionBar.tsx
@@ -2,7 +2,7 @@ import {
HIRING_DECISIONS,
type HiringDecision,
} from '@/features/manager/posting/lib/applicationStatus'
-import { LockIcon } from '@/features/manager/posting/ui/icons'
+import LockIcon from '@/assets/icons/posting/Lock.svg?react'
import { cn } from '@/shared/lib/utils'
interface HiringActionBarProps {
diff --git a/src/features/manager/posting/ui/PostingFormFields.tsx b/src/features/manager/posting/ui/PostingFormFields.tsx
index 5a790c80..ab96ebe3 100644
--- a/src/features/manager/posting/ui/PostingFormFields.tsx
+++ b/src/features/manager/posting/ui/PostingFormFields.tsx
@@ -5,7 +5,7 @@ import {
MOCK_KEYWORDS,
MOCK_WORKSPACES,
} from '@/features/manager/posting/mocks/data'
-import { AlertIcon } from '@/features/manager/posting/ui/icons'
+import AlertIcon from '@/assets/icons/posting/Alert.svg?react'
import { ScheduleEditor } from '@/features/manager/posting/ui/ScheduleEditor'
import {
MAX_KEYWORDS,
diff --git a/src/features/manager/posting/ui/PostingListCard.tsx b/src/features/manager/posting/ui/PostingListCard.tsx
index 9cb287ec..6925eacb 100644
--- a/src/features/manager/posting/ui/PostingListCard.tsx
+++ b/src/features/manager/posting/ui/PostingListCard.tsx
@@ -1,5 +1,6 @@
+import CalendarIcon from '@/assets/icons/posting/Calendar.svg?react'
+import ClockIcon from '@/assets/icons/posting/Clock.svg?react'
import { ManagerPostingStatusBadge } from '@/features/manager/posting/ui/ManagerPostingStatusBadge'
-import { CalendarIcon, ClockIcon } from '@/features/manager/posting/ui/icons'
import {
formatTimeRange,
formatWorkingDays,
diff --git a/src/features/manager/posting/ui/ScheduleEditor.tsx b/src/features/manager/posting/ui/ScheduleEditor.tsx
index e5407146..c2e269f3 100644
--- a/src/features/manager/posting/ui/ScheduleEditor.tsx
+++ b/src/features/manager/posting/ui/ScheduleEditor.tsx
@@ -1,5 +1,6 @@
+import CloseIcon from '@/assets/icons/posting/Close.svg?react'
+import PlusIcon from '@/assets/icons/posting/Plus.svg?react'
import type { PostingFormViewModel } from '@/features/manager/posting/hooks/usePostingForm'
-import { CloseIcon, PlusIcon } from '@/features/manager/posting/ui/icons'
import {
WORKING_DAYS,
WORKING_DAY_LABEL,
diff --git a/src/features/manager/posting/ui/icons.tsx b/src/features/manager/posting/ui/icons.tsx
deleted file mode 100644
index 574cabb2..00000000
--- a/src/features/manager/posting/ui/icons.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-/** 구인구직 화면 전용 아이콘 — 24 viewBox, 색상은 부모의 text-* 를 따릅니다 */
-
-interface IconProps {
- className?: string
-}
-
-export function CalendarIcon({ className }: IconProps) {
- return (
-
- )
-}
-
-export function ClockIcon({ className }: IconProps) {
- return (
-
- )
-}
-
-export function PersonIcon({ className }: IconProps) {
- return (
-
- )
-}
-
-export function ChevronRightIcon({ className }: IconProps) {
- return (
-
- )
-}
-
-export function PlusIcon({ className }: IconProps) {
- return (
-
- )
-}
-
-export function CloseIcon({ className }: IconProps) {
- return (
-
- )
-}
-
-export function AlertIcon({ className }: IconProps) {
- return (
-
- )
-}
-
-export function CheckCircleIcon({ className }: IconProps) {
- return (
-
- )
-}
-
-export function LockIcon({ className }: IconProps) {
- return (
-
- )
-}
diff --git a/src/pages/manager/application-detail/index.tsx b/src/pages/manager/application-detail/index.tsx
index 5f1ff0dd..349398f1 100644
--- a/src/pages/manager/application-detail/index.tsx
+++ b/src/pages/manager/application-detail/index.tsx
@@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom'
import { useApplicationDetailViewModel } from '@/features/manager/posting/hooks/useApplicationDetailViewModel'
import { HiringActionBar } from '@/features/manager/posting/ui/HiringActionBar'
import { ManagerApplicationStatusBadge } from '@/features/manager/posting/ui/ManagerApplicationStatusBadge'
-import { CheckCircleIcon } from '@/features/manager/posting/ui/icons'
+import CheckCircleIcon from '@/assets/icons/posting/CheckCircle.svg?react'
import {
formatTimeRange,
formatWorkingDays,
diff --git a/src/pages/manager/application-list/index.tsx b/src/pages/manager/application-list/index.tsx
index fce790a0..53d849ab 100644
--- a/src/pages/manager/application-list/index.tsx
+++ b/src/pages/manager/application-list/index.tsx
@@ -4,7 +4,7 @@ import { useApplicationListViewModel } from '@/features/manager/posting/hooks/us
import { APPLICATION_STATUS_FILTER_OPTIONS } from '@/features/manager/posting/lib/applicationStatus'
import { ApplicantCard } from '@/features/manager/posting/ui/ApplicantCard'
import { FilterBar } from '@/features/manager/posting/ui/FilterBar'
-import { PersonIcon } from '@/features/manager/posting/ui/icons'
+import PersonIcon from '@/assets/icons/posting/Person.svg?react'
import { managerPostingApplicationDetailPath } from '@/shared/constants/routes'
import { MoreButton } from '@/shared/ui/common/MoreButton'
import { Navbar } from '@/shared/ui/common/Navbar'
diff --git a/src/pages/manager/posting-detail/index.tsx b/src/pages/manager/posting-detail/index.tsx
index e843bf64..39d126c3 100644
--- a/src/pages/manager/posting-detail/index.tsx
+++ b/src/pages/manager/posting-detail/index.tsx
@@ -1,13 +1,11 @@
import { useNavigate, useParams } from 'react-router-dom'
+import CalendarIcon from '@/assets/icons/posting/Calendar.svg?react'
+import ChevronRightIcon from '@/assets/icons/posting/ChevronRight.svg?react'
+import ClockIcon from '@/assets/icons/posting/Clock.svg?react'
+import PersonIcon from '@/assets/icons/posting/Person.svg?react'
import { usePostingDetailViewModel } from '@/features/manager/posting/hooks/usePostingDetailViewModel'
import { ManagerPostingStatusBadge } from '@/features/manager/posting/ui/ManagerPostingStatusBadge'
-import {
- CalendarIcon,
- ChevronRightIcon,
- ClockIcon,
- PersonIcon,
-} from '@/features/manager/posting/ui/icons'
import {
formatTimeRange,
formatWorkingDays,
From edc1af562ca23c97e07dcbc1103d4797d44a2d53 Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Sun, 26 Jul 2026 21:07:10 +0900
Subject: [PATCH 07/33] =?UTF-8?q?feat:=20Navbar=20=EB=92=A4=EB=A1=9C?=
=?UTF-8?q?=EA=B0=80=EA=B8=B0=20=EC=88=A8=EA=B9=80=20=EC=98=B5=EC=85=98(sh?=
=?UTF-8?q?owBack)=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/shared/ui/common/Navbar.tsx | 21 +++++++++++++--------
1 file changed, 13 insertions(+), 8 deletions(-)
diff --git a/src/shared/ui/common/Navbar.tsx b/src/shared/ui/common/Navbar.tsx
index b3f67974..6dbcb10b 100644
--- a/src/shared/ui/common/Navbar.tsx
+++ b/src/shared/ui/common/Navbar.tsx
@@ -13,6 +13,8 @@ interface NavbarProps {
onBackClick?: () => void
/** 상세 헤더(`variant="detail"`) 우측 영역 — 알림·메뉴 자리에 커스텀 노출 시 사용 */
rightAction?: ReactNode
+ /** 뒤로가기 버튼 노출 — 기본 true. Docbar 탭 진입점처럼 돌아갈 곳이 없는 화면은 false */
+ showBack?: boolean
/** 하단 구분선 — 기본 true */
showBorder?: boolean
/** 메인 헤더 알림 뱃지 표시 여부 */
@@ -26,6 +28,7 @@ export function Navbar({
title = '',
onBackClick,
rightAction,
+ showBack = true,
showBorder = true,
hasUnread = false,
onNotificationClick,
@@ -55,14 +58,16 @@ export function Navbar({
알터
) : (
-
+ showBack && (
+
+ )
)}
From ca32bc238a8ed93987677f96415e166fc4223a7d Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Sun, 26 Jul 2026 21:07:19 +0900
Subject: [PATCH 08/33] =?UTF-8?q?fix:=20=EB=82=B4=20=EA=B3=B5=EA=B3=A0=20?=
=?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=95=98=EB=8B=A8=20CTA=20=EC=A0=9C?=
=?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20Navbar=20=EC=9E=91=EC=84=B1=20=EC=95=84?=
=?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/assets/icons/posting/Write.svg | 4 ++++
src/pages/manager/posting-list/index.tsx | 23 ++++++++++++++---------
2 files changed, 18 insertions(+), 9 deletions(-)
create mode 100644 src/assets/icons/posting/Write.svg
diff --git a/src/assets/icons/posting/Write.svg b/src/assets/icons/posting/Write.svg
new file mode 100644
index 00000000..88461cd8
--- /dev/null
+++ b/src/assets/icons/posting/Write.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/pages/manager/posting-list/index.tsx b/src/pages/manager/posting-list/index.tsx
index 291d8cff..e3a278c9 100644
--- a/src/pages/manager/posting-list/index.tsx
+++ b/src/pages/manager/posting-list/index.tsx
@@ -1,11 +1,11 @@
import { useNavigate } from 'react-router-dom'
+import WriteIcon from '@/assets/icons/posting/Write.svg?react'
import { usePostingListViewModel } from '@/features/manager/posting/hooks/usePostingListViewModel'
import { POSTING_STATUS_FILTER_OPTIONS } from '@/features/manager/posting/lib/postingStatus'
import { FilterBar } from '@/features/manager/posting/ui/FilterBar'
import { PostingListCard } from '@/features/manager/posting/ui/PostingListCard'
import { managerPostingDetailPath, ROUTES } from '@/shared/constants/routes'
-import { AuthButton } from '@/shared/ui/common/AuthButton'
import { Navbar } from '@/shared/ui/common/Navbar'
import { Skeleton } from '@/shared/ui/common/Skeleton'
@@ -47,10 +47,21 @@ export function ManagerPostingListPage() {
navigate(-1)}
+ showBack={false}
+ rightAction={
+
+ }
/>
-
+ {/* 하단 Docbar(h-14)에 가리지 않도록 여유 패딩 */}
+
) : null}
-
-
-
navigate(ROUTES.MANAGER.POSTING_NEW)}>
- 공고 작성
-
-
)
}
From 8689cc69e53a9a299e7c110d23ec9ced4d905d63 Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Sun, 26 Jul 2026 21:07:28 +0900
Subject: [PATCH 09/33] =?UTF-8?q?fix:=20=EA=B3=B5=EA=B3=A0=EB=B3=84=20?=
=?UTF-8?q?=EC=A7=80=EC=9B=90=EC=9E=90=20=EB=AA=A9=EB=A1=9D=20=EA=B2=BD?=
=?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20Docbar=20=EC=88=A8?=
=?UTF-8?q?=EA=B9=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/App.tsx | 8 ++++++++
src/pages/manager/application-list/index.tsx | 14 +++++++++-----
src/shared/constants/routes.ts | 8 ++++++--
3 files changed, 23 insertions(+), 7 deletions(-)
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 5b29df49..989a8a39 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -177,6 +177,14 @@ export function App() {
}
/>
+
+
+
+ }
+ />
-
+
{/* 하단 Docbar(h-14)에 가리지 않도록 여유 패딩 */}
diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts
index 115edc79..36753051 100644
--- a/src/shared/constants/routes.ts
+++ b/src/shared/constants/routes.ts
@@ -41,8 +41,11 @@ export const ROUTES = {
POSTINGS: '/manager/postings',
/** 공고 등록 */
POSTING_NEW: '/manager/postings/new',
- /** 지원자 목록 */
+ /** 지원자 목록 — Docbar 탭 진입점(전체 지원자) */
POSTING_APPLICATIONS: '/manager/postings/applications',
+ /** 특정 공고의 지원자 목록 — 공고 상세에서 진입(Docbar 없음) */
+ POSTING_APPLICATIONS_BY_POSTING_PATTERN:
+ '/manager/postings/:postingId/applications',
/** 지원자 상세·채용 결정 (파라미터) */
POSTING_APPLICATION_DETAIL_PATTERN:
'/manager/postings/applications/:applicationId',
@@ -95,9 +98,10 @@ export function managerPostingEditPath(postingId: number) {
}
export function managerPostingApplicationsPath(postingId?: number) {
+ // 공고 지정 시 Docbar 없는 하위 경로로, 미지정 시 탭 진입점으로
return postingId === undefined
? '/manager/postings/applications'
- : `/manager/postings/applications?postingId=${postingId}`
+ : `/manager/postings/${postingId}/applications`
}
export function managerPostingApplicationDetailPath(applicationId: number) {
From ae8ba1cd4948b9ecc1e6c107c557139c886e608c Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Mon, 27 Jul 2026 11:08:03 +0900
Subject: [PATCH 10/33] =?UTF-8?q?refactor:=20=EC=8B=9C=EA=B0=84=20?=
=?UTF-8?q?=ED=94=BD=EC=BB=A4=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20sha?=
=?UTF-8?q?red=20=EA=B3=84=EC=B8=B5=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../worker-schedule/components/FixedScheduleDateSection.tsx | 2 +-
.../components/GeneralScheduleDateSection.tsx | 2 +-
.../worker-schedule/components/WorkTimeRangeField.tsx | 6 +++---
.../worker-schedule => shared}/lib/formatKoreanWorkTime.ts | 0
.../manager/worker-schedule => shared}/types/workTime.ts | 0
.../components => shared/ui/common}/WheelPicker.tsx | 0
.../ui/common}/WorkTimePickerDrawer.tsx | 6 +++---
7 files changed, 8 insertions(+), 8 deletions(-)
rename src/{pages/manager/worker-schedule => shared}/lib/formatKoreanWorkTime.ts (100%)
rename src/{pages/manager/worker-schedule => shared}/types/workTime.ts (100%)
rename src/{pages/manager/worker-schedule/components => shared/ui/common}/WheelPicker.tsx (100%)
rename src/{pages/manager/worker-schedule/components => shared/ui/common}/WorkTimePickerDrawer.tsx (93%)
diff --git a/src/pages/manager/worker-schedule/components/FixedScheduleDateSection.tsx b/src/pages/manager/worker-schedule/components/FixedScheduleDateSection.tsx
index 6eb50bc0..7c751aa7 100644
--- a/src/pages/manager/worker-schedule/components/FixedScheduleDateSection.tsx
+++ b/src/pages/manager/worker-schedule/components/FixedScheduleDateSection.tsx
@@ -7,7 +7,7 @@ import { ScheduleDateDisplayRow } from '@/pages/manager/worker-schedule/componen
import { ScheduleDatePickerDrawer } from '@/pages/manager/worker-schedule/components/ScheduleDatePickerDrawer'
import { WeekdayPicker } from '@/pages/manager/worker-schedule/components/WeekdayPicker'
import { WorkTimeRangeField } from '@/pages/manager/worker-schedule/components/WorkTimeRangeField'
-import type { WorkTimeEditorState } from '@/pages/manager/worker-schedule/types/workTime'
+import type { WorkTimeEditorState } from '@/shared/types/workTime'
type DatePickerTarget = 'start' | 'end' | null
diff --git a/src/pages/manager/worker-schedule/components/GeneralScheduleDateSection.tsx b/src/pages/manager/worker-schedule/components/GeneralScheduleDateSection.tsx
index fff95e39..4b194c32 100644
--- a/src/pages/manager/worker-schedule/components/GeneralScheduleDateSection.tsx
+++ b/src/pages/manager/worker-schedule/components/GeneralScheduleDateSection.tsx
@@ -1,7 +1,7 @@
import calendarIcon from '@/assets/icons/schedule/schedule_calendar.svg'
import { CollapsibleScheduleSection } from '@/pages/manager/worker-schedule/components/CollapsibleScheduleSection'
import { WorkTimeRangeField } from '@/pages/manager/worker-schedule/components/WorkTimeRangeField'
-import type { WorkTimeEditorState } from '@/pages/manager/worker-schedule/types/workTime'
+import type { WorkTimeEditorState } from '@/shared/types/workTime'
import { ManagerMonthCalendar } from '@/shared/ui/schedule/ManagerMonthCalendar'
interface GeneralScheduleDateSectionProps {
diff --git a/src/pages/manager/worker-schedule/components/WorkTimeRangeField.tsx b/src/pages/manager/worker-schedule/components/WorkTimeRangeField.tsx
index c75a3b6f..d06b0b53 100644
--- a/src/pages/manager/worker-schedule/components/WorkTimeRangeField.tsx
+++ b/src/pages/manager/worker-schedule/components/WorkTimeRangeField.tsx
@@ -1,7 +1,7 @@
import { useState } from 'react'
-import { formatKoreanTimePart } from '@/pages/manager/worker-schedule/lib/formatKoreanWorkTime'
-import { WorkTimePickerDrawer } from '@/pages/manager/worker-schedule/components/WorkTimePickerDrawer'
-import type { WorkTimeEditorState } from '@/pages/manager/worker-schedule/types/workTime'
+import { formatKoreanTimePart } from '@/shared/lib/formatKoreanWorkTime'
+import { WorkTimePickerDrawer } from '@/shared/ui/common/WorkTimePickerDrawer'
+import type { WorkTimeEditorState } from '@/shared/types/workTime'
import { cn } from '@/shared/lib/utils'
type EditTarget = 'start' | 'end'
diff --git a/src/pages/manager/worker-schedule/lib/formatKoreanWorkTime.ts b/src/shared/lib/formatKoreanWorkTime.ts
similarity index 100%
rename from src/pages/manager/worker-schedule/lib/formatKoreanWorkTime.ts
rename to src/shared/lib/formatKoreanWorkTime.ts
diff --git a/src/pages/manager/worker-schedule/types/workTime.ts b/src/shared/types/workTime.ts
similarity index 100%
rename from src/pages/manager/worker-schedule/types/workTime.ts
rename to src/shared/types/workTime.ts
diff --git a/src/pages/manager/worker-schedule/components/WheelPicker.tsx b/src/shared/ui/common/WheelPicker.tsx
similarity index 100%
rename from src/pages/manager/worker-schedule/components/WheelPicker.tsx
rename to src/shared/ui/common/WheelPicker.tsx
diff --git a/src/pages/manager/worker-schedule/components/WorkTimePickerDrawer.tsx b/src/shared/ui/common/WorkTimePickerDrawer.tsx
similarity index 93%
rename from src/pages/manager/worker-schedule/components/WorkTimePickerDrawer.tsx
rename to src/shared/ui/common/WorkTimePickerDrawer.tsx
index d09822bd..f2b77da0 100644
--- a/src/pages/manager/worker-schedule/components/WorkTimePickerDrawer.tsx
+++ b/src/shared/ui/common/WorkTimePickerDrawer.tsx
@@ -1,5 +1,5 @@
import { Drawer } from 'vaul'
-import { WheelPicker } from '@/pages/manager/worker-schedule/components/WheelPicker'
+import { WheelPicker } from '@/shared/ui/common/WheelPicker'
import {
hour24To12Parts,
partsToHour24,
@@ -7,8 +7,8 @@ import {
minuteToTenMinuteIndex,
WORK_TIME_MINUTE_OPTIONS,
type TimePeriod,
-} from '@/pages/manager/worker-schedule/lib/formatKoreanWorkTime'
-import type { WorkTimeEditorState } from '@/pages/manager/worker-schedule/types/workTime'
+} from '@/shared/lib/formatKoreanWorkTime'
+import type { WorkTimeEditorState } from '@/shared/types/workTime'
const PERIOD_ITEMS = ['오전', '오후'] as const
const HOUR_ITEMS = Array.from({ length: 12 }, (_, i) => `${i + 1}시`)
From 048df1c26d42abee8548f6be4793165de25f7fb2 Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Mon, 27 Jul 2026 11:08:20 +0900
Subject: [PATCH 11/33] =?UTF-8?q?feat:=20=EA=B7=BC=EB=AC=B4=EC=9D=BC?=
=?UTF-8?q?=EC=A0=95=20=EC=8B=9C=EA=B0=84=20=EC=9E=85=EB=A0=A5=EC=9D=84=20?=
=?UTF-8?q?=EC=8B=9C=EA=B0=84=20=ED=94=BD=EC=BB=A4=EB=A1=9C=20=EA=B5=90?=
=?UTF-8?q?=EC=B2=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../manager/posting/ui/ScheduleEditor.tsx | 123 ++++++++++++++----
1 file changed, 97 insertions(+), 26 deletions(-)
diff --git a/src/features/manager/posting/ui/ScheduleEditor.tsx b/src/features/manager/posting/ui/ScheduleEditor.tsx
index c2e269f3..87a90119 100644
--- a/src/features/manager/posting/ui/ScheduleEditor.tsx
+++ b/src/features/manager/posting/ui/ScheduleEditor.tsx
@@ -1,6 +1,11 @@
import CloseIcon from '@/assets/icons/posting/Close.svg?react'
import PlusIcon from '@/assets/icons/posting/Plus.svg?react'
+import { useState } from 'react'
+
import type { PostingFormViewModel } from '@/features/manager/posting/hooks/usePostingForm'
+import { formatKoreanTimePart } from '@/shared/lib/formatKoreanWorkTime'
+import type { WorkTimeEditorState } from '@/shared/types/workTime'
+import { WorkTimePickerDrawer } from '@/shared/ui/common/WorkTimePickerDrawer'
import {
WORKING_DAYS,
WORKING_DAY_LABEL,
@@ -12,6 +17,48 @@ interface ScheduleEditorProps {
form: PostingFormViewModel
}
+type TimeTarget = 'start' | 'end'
+
+/** 'HH:MM' → { hour, minute }. 미입력이면 빈 문자열 */
+function splitTime(time: string) {
+ const [hour = '', minute = ''] = time.split(':')
+ return { hour, minute }
+}
+
+/** 시간 선택 버튼 — 미입력 시 플레이스홀더를 노출합니다 */
+function TimeField({
+ label,
+ value,
+ onOpen,
+}: {
+ label: string
+ value: string
+ onOpen: () => void
+}) {
+ const { hour, minute } = splitTime(value)
+ const isEmpty = value === ''
+
+ return (
+
+
+ {label}
+
+
+
+ )
+}
+
function ScheduleCard({
schedule,
index,
@@ -24,6 +71,37 @@ function ScheduleCard({
canRemove: boolean
}) {
const isExisting = schedule.id !== null
+ const [pickerTarget, setPickerTarget] = useState(null)
+
+ const start = splitTime(schedule.startTime)
+ const end = splitTime(schedule.endTime)
+
+ /**
+ * 폼의 'HH:MM' 문자열을 픽커가 요구하는 시/분 단위 상태로 어댑트합니다.
+ * 한쪽만 선택된 경우 나머지는 '00'으로 채웁니다.
+ */
+ const workTime: WorkTimeEditorState = {
+ startHour: start.hour,
+ startMinute: start.minute,
+ endHour: end.hour,
+ endMinute: end.minute,
+ setStartHour: hour =>
+ form.updateSchedule(schedule.key, {
+ startTime: `${hour}:${start.minute || '00'}`,
+ }),
+ setStartMinute: minute =>
+ form.updateSchedule(schedule.key, {
+ startTime: `${start.hour || '00'}:${minute}`,
+ }),
+ setEndHour: hour =>
+ form.updateSchedule(schedule.key, {
+ endTime: `${hour}:${end.minute || '00'}`,
+ }),
+ setEndMinute: minute =>
+ form.updateSchedule(schedule.key, {
+ endTime: `${end.hour || '00'}:${minute}`,
+ }),
+ }
return (
-
-
+ setPickerTarget('start')}
+ />
+ setPickerTarget('end')}
+ />
+
{
+ if (!open) setPickerTarget(null)
+ }}
+ />
+
-
- {posting.keywords.length > 0 ? (
-
- {posting.keywords.map(keyword => (
-
- {keyword}
-
- ))}
-
- ) : null}
From 0b37eb2ccd9746c2ffc2016da4b7f02e2e7b5bf8 Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Mon, 27 Jul 2026 11:59:27 +0900
Subject: [PATCH 13/33] =?UTF-8?q?fix:=20=EC=A7=80=EC=9B=90=EC=9E=90=20?=
=?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=8D=94=EB=B3=B4=EA=B8=B0=20=EB=B2=84?=
=?UTF-8?q?=ED=8A=BC=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EB=AC=B4=ED=95=9C?=
=?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=A0=81=EC=9A=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../hooks/useApplicationListViewModel.ts | 52 +++++++++++++++----
src/pages/manager/application-list/index.tsx | 35 ++++++++++++-
2 files changed, 76 insertions(+), 11 deletions(-)
diff --git a/src/features/manager/posting/hooks/useApplicationListViewModel.ts b/src/features/manager/posting/hooks/useApplicationListViewModel.ts
index 6aab2d49..d8f30246 100644
--- a/src/features/manager/posting/hooks/useApplicationListViewModel.ts
+++ b/src/features/manager/posting/hooks/useApplicationListViewModel.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ApplicationStatusFilter } from '@/features/manager/posting/lib/applicationStatus'
import { MOCK_WORKSPACES } from '@/features/manager/posting/mocks/data'
@@ -31,23 +31,49 @@ export function useApplicationListViewModel({
useState('ALL')
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
const [isLoading, setIsLoading] = useState(true)
+ const [isFetchingNextPage, setIsFetchingNextPage] = useState(false)
+ /** 다음 페이지 로딩 타이머 — 언마운트·필터 변경 시 취소 */
+ const nextPageTimer = useRef | null>(null)
useEffect(() => {
const timer = setTimeout(() => setIsLoading(false), MOCK_LATENCY)
return () => clearTimeout(timer)
}, [])
- // 필터가 바뀌면 페이지를 처음으로 되돌림
- const setWorkspaceFilter = useCallback((value: WorkspaceFilter) => {
- setWorkspaceFilterState(value)
- setVisibleCount(PAGE_SIZE)
- }, [])
+ useEffect(
+ () => () => {
+ if (nextPageTimer.current) clearTimeout(nextPageTimer.current)
+ },
+ []
+ )
- const setStatusFilter = useCallback((value: ApplicationStatusFilter) => {
- setStatusFilterState(value)
+ /** 진행 중인 다음 페이지 로딩을 취소하고 첫 페이지로 되돌림 */
+ const resetPaging = useCallback(() => {
+ if (nextPageTimer.current) {
+ clearTimeout(nextPageTimer.current)
+ nextPageTimer.current = null
+ }
+ setIsFetchingNextPage(false)
setVisibleCount(PAGE_SIZE)
}, [])
+ // 필터가 바뀌면 페이지를 처음으로 되돌림
+ const setWorkspaceFilter = useCallback(
+ (value: WorkspaceFilter) => {
+ setWorkspaceFilterState(value)
+ resetPaging()
+ },
+ [resetPaging]
+ )
+
+ const setStatusFilter = useCallback(
+ (value: ApplicationStatusFilter) => {
+ setStatusFilterState(value)
+ resetPaging()
+ },
+ [resetPaging]
+ )
+
const filteredApplications = useMemo(
() =>
applications.filter(application => {
@@ -71,8 +97,15 @@ export function useApplicationListViewModel({
const visibleApplications = filteredApplications.slice(0, visibleCount)
const hasNextPage = visibleCount < filteredApplications.length
+ /** 커서 기반 다음 페이지 로드 — 실제 API 연동 시 useInfiniteQuery로 교체 */
const fetchNextPage = useCallback(() => {
- setVisibleCount(prev => prev + PAGE_SIZE)
+ if (nextPageTimer.current) return
+ setIsFetchingNextPage(true)
+ nextPageTimer.current = setTimeout(() => {
+ setVisibleCount(prev => prev + PAGE_SIZE)
+ setIsFetchingNextPage(false)
+ nextPageTimer.current = null
+ }, MOCK_LATENCY)
}, [])
const workspaceOptions = useMemo(
@@ -92,6 +125,7 @@ export function useApplicationListViewModel({
isLoading,
isEmpty: !isLoading && filteredApplications.length === 0,
hasNextPage,
+ isFetchingNextPage,
fetchNextPage,
workspaceFilter,
setWorkspaceFilter,
diff --git a/src/pages/manager/application-list/index.tsx b/src/pages/manager/application-list/index.tsx
index f1cfd1da..ec38c895 100644
--- a/src/pages/manager/application-list/index.tsx
+++ b/src/pages/manager/application-list/index.tsx
@@ -1,3 +1,4 @@
+import { useEffect, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useApplicationListViewModel } from '@/features/manager/posting/hooks/useApplicationListViewModel'
@@ -6,8 +7,8 @@ import { ApplicantCard } from '@/features/manager/posting/ui/ApplicantCard'
import { FilterBar } from '@/features/manager/posting/ui/FilterBar'
import PersonIcon from '@/assets/icons/posting/Person.svg?react'
import { managerPostingApplicationDetailPath } from '@/shared/constants/routes'
-import { MoreButton } from '@/shared/ui/common/MoreButton'
import { Navbar } from '@/shared/ui/common/Navbar'
+import { Spinner } from '@/shared/ui/Spinner'
import { Skeleton } from '@/shared/ui/common/Skeleton'
function ApplicantListSkeleton() {
@@ -49,6 +50,7 @@ export function ManagerApplicationListPage() {
isLoading,
isEmpty,
hasNextPage,
+ isFetchingNextPage,
fetchNextPage,
workspaceFilter,
setWorkspaceFilter,
@@ -57,6 +59,28 @@ export function ManagerApplicationListPage() {
workspaceOptions,
} = useApplicationListViewModel({ postingId })
+ // 커서 기반 무한스크롤 — 목록 하단 sentinel이 보이면 다음 페이지 로드
+ const sentinelRef = useRef(null)
+ /** sentinel은 로딩이 끝난 뒤에야 마운트되므로 deps에 isLoading·개수를 포함해야 관찰이 붙는다 */
+ const visibleCount = applications.length
+
+ useEffect(() => {
+ const el = sentinelRef.current
+ if (!el) return
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage()
+ }
+ },
+ { threshold: 0.1 }
+ )
+
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage, isLoading, visibleCount])
+
return (
@@ -107,7 +131,14 @@ export function ManagerApplicationListPage() {
))}
- {hasNextPage ?
: null}
+ {/* 무한스크롤 감지 지점 */}
+
+ {isFetchingNextPage ? : null}
+
>
) : null}
From 6af44dc8f44841851870d23d6c4184077043f26c Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Mon, 27 Jul 2026 12:23:39 +0900
Subject: [PATCH 14/33] =?UTF-8?q?fix:=20=EA=B3=B5=EA=B3=A0=20=EC=88=98?=
=?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20=EC=97=85=EC=9E=A5=20=EB=B3=80=EA=B2=BD?=
=?UTF-8?q?=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/features/manager/posting/ui/PostingFormFields.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/features/manager/posting/ui/PostingFormFields.tsx b/src/features/manager/posting/ui/PostingFormFields.tsx
index 1dc169fe..d89fc1b2 100644
--- a/src/features/manager/posting/ui/PostingFormFields.tsx
+++ b/src/features/manager/posting/ui/PostingFormFields.tsx
@@ -69,12 +69,14 @@ export function PostingFormFields({ form }: PostingFormFieldsProps) {
return (
+ {/* 공고의 소속 업장은 등록 시 확정 — 수정 화면에서는 변경 불가 */}
From 582ea84e80354307241b3ed810ba79c84db3c18c Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Mon, 27 Jul 2026 12:25:41 +0900
Subject: [PATCH 15/33] =?UTF-8?q?fix:=20=EA=B7=BC=EB=AC=B4=EC=9D=BC?=
=?UTF-8?q?=EC=A0=95=20=EB=AA=A8=EC=A7=91=20=EC=9D=B8=EC=9B=90=C2=B7?=
=?UTF-8?q?=EC=8B=9C=EA=B0=84=20=ED=94=BD=EC=BB=A4=20=EC=9E=85=EB=A0=A5=20?=
=?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/features/manager/posting/mocks/store.ts | 3 +-
.../manager/posting/ui/ScheduleEditor.tsx | 34 ++++++++++++++++---
2 files changed, 32 insertions(+), 5 deletions(-)
diff --git a/src/features/manager/posting/mocks/store.ts b/src/features/manager/posting/mocks/store.ts
index 14d1d43a..4250768a 100644
--- a/src/features/manager/posting/mocks/store.ts
+++ b/src/features/manager/posting/mocks/store.ts
@@ -52,7 +52,8 @@ function toPostingSchedules(
startTime: schedule.startTime,
endTime: schedule.endTime,
position: schedule.position,
- positionsNeeded: schedule.positionsNeeded,
+ // 편집 중 빈 값(0)이 남아 있어도 저장 시 최소 1명 보장
+ positionsNeeded: Math.max(1, schedule.positionsNeeded),
}))
}
diff --git a/src/features/manager/posting/ui/ScheduleEditor.tsx b/src/features/manager/posting/ui/ScheduleEditor.tsx
index 87a90119..cc9106ca 100644
--- a/src/features/manager/posting/ui/ScheduleEditor.tsx
+++ b/src/features/manager/posting/ui/ScheduleEditor.tsx
@@ -73,6 +73,21 @@ function ScheduleCard({
const isExisting = schedule.id !== null
const [pickerTarget, setPickerTarget] = useState(null)
+ /**
+ * 픽커는 휠 조작 시에만 onChange를 발생시키므로, 빈 값인 채로 열면
+ * 하이라이트된 '오전 12시 00분'을 그대로 닫아도 아무것도 기록되지 않습니다.
+ * 열기 전에 기본값(00:00)을 먼저 커밋해 보이는 값과 기록 값을 일치시킵니다.
+ */
+ const openPicker = (target: TimeTarget) => {
+ if (target === 'start' && schedule.startTime === '') {
+ form.updateSchedule(schedule.key, { startTime: '00:00' })
+ }
+ if (target === 'end' && schedule.endTime === '') {
+ form.updateSchedule(schedule.key, { endTime: '00:00' })
+ }
+ setPickerTarget(target)
+ }
+
const start = splitTime(schedule.startTime)
const end = splitTime(schedule.endTime)
@@ -166,12 +181,12 @@ function ScheduleCard({
setPickerTarget('start')}
+ onOpen={() => openPicker('start')}
/>
setPickerTarget('end')}
+ onOpen={() => openPicker('end')}
/>
@@ -203,13 +218,24 @@ function ScheduleCard({
모집 인원
+ {/* 편집 중에는 빈 값(0)을 허용하고 blur 시점에 최소 1로 보정 */}
form.updateSchedule(schedule.key, {
- positionsNeeded: Math.max(1, Number(e.target.value) || 1),
+ positionsNeeded: Math.max(
+ 0,
+ Number(e.target.value.replace(/[^0-9]/g, '')) || 0
+ ),
+ })
+ }
+ onBlur={() =>
+ form.updateSchedule(schedule.key, {
+ positionsNeeded: Math.max(1, schedule.positionsNeeded),
})
}
className="h-11 w-full rounded-xl border border-line-1 bg-white px-3 typography-body02-regular text-text-100 focus:border-main focus:outline-none"
From b5217d0f0203e545f27372809f1553470c8a935b Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Mon, 27 Jul 2026 12:26:38 +0900
Subject: [PATCH 16/33] =?UTF-8?q?fix:=20=EA=B3=B5=EA=B3=A0=20=EC=88=98?=
=?UTF-8?q?=EC=A0=95=20=ED=8F=BC=20=EB=A6=AC=EB=A7=88=EC=9A=B4=ED=8A=B8=20?=
=?UTF-8?q?key=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/pages/manager/posting-edit/index.tsx | 14 +++++---------
1 file changed, 5 insertions(+), 9 deletions(-)
diff --git a/src/pages/manager/posting-edit/index.tsx b/src/pages/manager/posting-edit/index.tsx
index eb164c08..df363bd2 100644
--- a/src/pages/manager/posting-edit/index.tsx
+++ b/src/pages/manager/posting-edit/index.tsx
@@ -36,8 +36,9 @@ export function ManagerPostingEditPage() {
}
return (
+ // key로 공고 전환 시 컴포넌트를 리마운트해 폼 초기값(usePostingForm의 lazy useState)을 재계산
{
updatePosting(numericPostingId, values)
@@ -49,20 +50,15 @@ export function ManagerPostingEditPage() {
}
interface PostingEditContentProps {
- postingId: number
posting: Posting
onSubmit: (values: PostingFormValues) => void
}
/**
* 폼 초기값이 `posting`에 의존하므로, 공고를 찾은 뒤에 마운트되도록 분리합니다
- * (훅 순서를 안정적으로 유지).
+ * (훅 순서를 안정적으로 유지). 공고 전환 시 리마운트는 호출부의 key가 담당합니다.
*/
-function PostingEditContent({
- postingId,
- posting,
- onSubmit,
-}: PostingEditContentProps) {
+function PostingEditContent({ posting, onSubmit }: PostingEditContentProps) {
const form = usePostingForm({ posting })
const handleSubmit = () => {
@@ -71,7 +67,7 @@ function PostingEditContent({
}
return (
-
+
From 357ee28068df7f420c912c9224755f52ad05de03 Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Mon, 27 Jul 2026 12:29:08 +0900
Subject: [PATCH 17/33] =?UTF-8?q?refactor:=20=EC=9A=94=EC=9D=BC=C2=B7?=
=?UTF-8?q?=EA=B8=89=EC=97=AC=C2=B7=EC=83=81=EB=8C=80=EC=8B=9C=EA=B0=84=20?=
=?UTF-8?q?=ED=8F=AC=EB=A7=B7=ED=84=B0=20shared=20=EC=B6=94=EC=B6=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/features/manager/home/types/posting.ts | 31 ++--------
src/features/manager/posting/types/posting.ts | 62 +++----------------
src/shared/constants/payment.ts | 11 ++++
src/shared/constants/workingDays.ts | 22 +++++++
src/shared/lib/formatRelativeTime.ts | 23 +++++++
5 files changed, 70 insertions(+), 79 deletions(-)
create mode 100644 src/shared/constants/payment.ts
create mode 100644 src/shared/constants/workingDays.ts
create mode 100644 src/shared/lib/formatRelativeTime.ts
diff --git a/src/features/manager/home/types/posting.ts b/src/features/manager/home/types/posting.ts
index b627d1fa..fcd76bdc 100644
--- a/src/features/manager/home/types/posting.ts
+++ b/src/features/manager/home/types/posting.ts
@@ -1,3 +1,5 @@
+import { PAYMENT_TYPE_LABEL as SHARED_PAYMENT_TYPE_LABEL } from '@/shared/constants/payment'
+import { WORKING_DAYS, WORKING_DAY_LABEL } from '@/shared/constants/workingDays'
import type { CommonApiResponse } from '@/shared/types/common'
import type { JobPostingItem } from '@/shared/ui/manager/OngoingPostingCard'
@@ -51,22 +53,10 @@ export interface ManagedPostingsQueryParams {
}
// ---- Mappers ----
-const PAYMENT_TYPE_LABEL: Record = {
- HOURLY: '시급',
- DAILY: '일급',
- MONTHLY: '월급',
- WEEKLY: '주급',
-}
+// 서버 응답의 paymentType/workingDays는 string이므로 안전한 폴백을 위해 넓은 타입으로 참조
+const PAYMENT_TYPE_LABEL: Record = SHARED_PAYMENT_TYPE_LABEL
-const WORKING_DAY_KO: Record = {
- MONDAY: '월',
- TUESDAY: '화',
- WEDNESDAY: '수',
- THURSDAY: '목',
- FRIDAY: '금',
- SATURDAY: '토',
- SUNDAY: '일',
-}
+const WORKING_DAY_KO: Record = WORKING_DAY_LABEL
function formatWage(payAmount: number, paymentType: string): string {
const label = PAYMENT_TYPE_LABEL[paymentType] ?? paymentType
@@ -84,17 +74,8 @@ function formatWorkHours(schedules: PostingScheduleDto[]): string {
function formatWorkDays(schedules: PostingScheduleDto[]): string {
if (schedules.length === 0) return '-'
// 모든 스케줄의 요일을 합산 후 중복 제거 + 요일 순서 정렬
- const DAY_ORDER = [
- 'MONDAY',
- 'TUESDAY',
- 'WEDNESDAY',
- 'THURSDAY',
- 'FRIDAY',
- 'SATURDAY',
- 'SUNDAY',
- ]
const daySet = new Set(schedules.flatMap(s => s.workingDays))
- return DAY_ORDER.filter(d => daySet.has(d))
+ return WORKING_DAYS.filter(d => daySet.has(d))
.map(d => WORKING_DAY_KO[d] ?? d)
.join(', ')
}
diff --git a/src/features/manager/posting/types/posting.ts b/src/features/manager/posting/types/posting.ts
index dd5acf2c..50aee4e9 100644
--- a/src/features/manager/posting/types/posting.ts
+++ b/src/features/manager/posting/types/posting.ts
@@ -6,40 +6,14 @@
* API 미연동 단계이므로 화면이 소비하는 UI 모델을 이 파일에서 정의합니다.
*/
import type { ApplicationApiStatus } from '@/features/user/home/applied-stores/types/application'
+import type { PaymentType } from '@/shared/constants/payment'
+import type { WorkingDay } from '@/shared/constants/workingDays'
+import { WORKING_DAYS, WORKING_DAY_LABEL } from '@/shared/constants/workingDays'
-// ---- 요일 ----
-export const WORKING_DAYS = [
- 'MONDAY',
- 'TUESDAY',
- 'WEDNESDAY',
- 'THURSDAY',
- 'FRIDAY',
- 'SATURDAY',
- 'SUNDAY',
-] as const
-
-export type WorkingDay = (typeof WORKING_DAYS)[number]
-
-export const WORKING_DAY_LABEL: Record = {
- MONDAY: '월',
- TUESDAY: '화',
- WEDNESDAY: '수',
- THURSDAY: '목',
- FRIDAY: '금',
- SATURDAY: '토',
- SUNDAY: '일',
-}
-
-// ---- 급여 ----
-export const PAYMENT_TYPES = ['HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY'] as const
-export type PaymentType = (typeof PAYMENT_TYPES)[number]
-
-export const PAYMENT_TYPE_LABEL: Record = {
- HOURLY: '시급',
- DAILY: '일급',
- WEEKLY: '주급',
- MONTHLY: '월급',
-}
+// ---- 요일·급여 (shared 재노출 — 슬라이스 내 호출부 경로 유지) ----
+export type { PaymentType, WorkingDay }
+export { PAYMENT_TYPES, PAYMENT_TYPE_LABEL } from '@/shared/constants/payment'
+export { WORKING_DAYS, WORKING_DAY_LABEL }
// ---- 공고 상태 ----
/** OPEN=모집중, CLOSED=모집완료 */
@@ -138,10 +112,6 @@ export interface PostingFormErrors {
}
// ---- 포맷터 ----
-export function formatPay(paymentType: PaymentType, payAmount: number): string {
- return `${PAYMENT_TYPE_LABEL[paymentType]} ${payAmount.toLocaleString('ko-KR')}원`
-}
-
export function formatWorkingDays(days: WorkingDay[]): string {
if (days.length === 0) return '-'
return WORKING_DAYS.filter(day => days.includes(day))
@@ -153,20 +123,4 @@ export function formatTimeRange(startTime: string, endTime: string): string {
return `${startTime}~${endTime}`
}
-/** 지원 시각을 '방금 전 / N시간 전 / 어제 / N일 전' 형태로 변환 */
-export function formatRelativeTime(isoDate: string, now = new Date()): string {
- const target = new Date(isoDate)
- const diffMs = now.getTime() - target.getTime()
- if (Number.isNaN(diffMs)) return ''
-
- const diffMinutes = Math.floor(diffMs / (1000 * 60))
- if (diffMinutes < 1) return '방금 전'
- if (diffMinutes < 60) return `${diffMinutes}분 전`
-
- const diffHours = Math.floor(diffMinutes / 60)
- if (diffHours < 24) return `${diffHours}시간 전`
-
- const diffDays = Math.floor(diffHours / 24)
- if (diffDays === 1) return '어제'
- return `${diffDays}일 전`
-}
+export { formatRelativeTime } from '@/shared/lib/formatRelativeTime'
diff --git a/src/shared/constants/payment.ts b/src/shared/constants/payment.ts
new file mode 100644
index 00000000..732ea9db
--- /dev/null
+++ b/src/shared/constants/payment.ts
@@ -0,0 +1,11 @@
+/** 급여 지급 형태 — 서버 enum */
+export const PAYMENT_TYPES = ['HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY'] as const
+
+export type PaymentType = (typeof PAYMENT_TYPES)[number]
+
+export const PAYMENT_TYPE_LABEL: Record = {
+ HOURLY: '시급',
+ DAILY: '일급',
+ WEEKLY: '주급',
+ MONTHLY: '월급',
+}
diff --git a/src/shared/constants/workingDays.ts b/src/shared/constants/workingDays.ts
new file mode 100644
index 00000000..b3f2eb18
--- /dev/null
+++ b/src/shared/constants/workingDays.ts
@@ -0,0 +1,22 @@
+/** 서버 요일 enum 순서 — 표시 정렬 기준으로도 사용합니다 */
+export const WORKING_DAYS = [
+ 'MONDAY',
+ 'TUESDAY',
+ 'WEDNESDAY',
+ 'THURSDAY',
+ 'FRIDAY',
+ 'SATURDAY',
+ 'SUNDAY',
+] as const
+
+export type WorkingDay = (typeof WORKING_DAYS)[number]
+
+export const WORKING_DAY_LABEL: Record = {
+ MONDAY: '월',
+ TUESDAY: '화',
+ WEDNESDAY: '수',
+ THURSDAY: '목',
+ FRIDAY: '금',
+ SATURDAY: '토',
+ SUNDAY: '일',
+}
diff --git a/src/shared/lib/formatRelativeTime.ts b/src/shared/lib/formatRelativeTime.ts
new file mode 100644
index 00000000..9f31d00e
--- /dev/null
+++ b/src/shared/lib/formatRelativeTime.ts
@@ -0,0 +1,23 @@
+/**
+ * 시각을 '방금 전 / N분 전 / N시간 전 / 어제 / N일 전' 형태로 변환합니다.
+ *
+ * 유사 구현이 2곳 더 있으나 출력 규칙이 달라 통합하지 않았습니다:
+ * - features/user/substitute/lib/adaptUserSubstituteRequest.ts — '어제' 없음, NaN → '-'
+ * - features/notification/useNotificationViewModel.ts — '방금 전' 없음
+ */
+export function formatRelativeTime(isoDate: string, now = new Date()): string {
+ const target = new Date(isoDate)
+ const diffMs = now.getTime() - target.getTime()
+ if (Number.isNaN(diffMs)) return ''
+
+ const diffMinutes = Math.floor(diffMs / (1000 * 60))
+ if (diffMinutes < 1) return '방금 전'
+ if (diffMinutes < 60) return `${diffMinutes}분 전`
+
+ const diffHours = Math.floor(diffMinutes / 60)
+ if (diffHours < 24) return `${diffHours}시간 전`
+
+ const diffDays = Math.floor(diffHours / 24)
+ if (diffDays === 1) return '어제'
+ return `${diffDays}일 전`
+}
From 9f5e8fb56182377fb08bfc03dc8daf633837d3c5 Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Mon, 27 Jul 2026 12:31:35 +0900
Subject: [PATCH 18/33] =?UTF-8?q?refactor:=20ApplicationApiStatus=20shared?=
=?UTF-8?q?=20=ED=83=80=EC=9E=85=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/features/manager/posting/types/posting.ts | 4 ++--
.../user/home/applied-stores/types/application.ts | 11 +++--------
src/shared/types/applicationStatus.ts | 12 ++++++++++++
3 files changed, 17 insertions(+), 10 deletions(-)
create mode 100644 src/shared/types/applicationStatus.ts
diff --git a/src/features/manager/posting/types/posting.ts b/src/features/manager/posting/types/posting.ts
index 50aee4e9..4b5a3d5c 100644
--- a/src/features/manager/posting/types/posting.ts
+++ b/src/features/manager/posting/types/posting.ts
@@ -2,10 +2,10 @@
* 사장님 구인구직 — 매니저 측 공고/지원자 UI 모델
*
* 서버 DTO(`@/features/manager/home/types/posting`)와 지원 상태 enum
- * (`@/features/user/home/applied-stores/types/application`)을 계승합니다.
+ * (`@/shared/types/applicationStatus`)을 계승합니다.
* API 미연동 단계이므로 화면이 소비하는 UI 모델을 이 파일에서 정의합니다.
*/
-import type { ApplicationApiStatus } from '@/features/user/home/applied-stores/types/application'
+import type { ApplicationApiStatus } from '@/shared/types/applicationStatus'
import type { PaymentType } from '@/shared/constants/payment'
import type { WorkingDay } from '@/shared/constants/workingDays'
import { WORKING_DAYS, WORKING_DAY_LABEL } from '@/shared/constants/workingDays'
diff --git a/src/features/user/home/applied-stores/types/application.ts b/src/features/user/home/applied-stores/types/application.ts
index 386fc126..b7f7e5b5 100644
--- a/src/features/user/home/applied-stores/types/application.ts
+++ b/src/features/user/home/applied-stores/types/application.ts
@@ -4,16 +4,11 @@ import type {
FilterType,
WeekdayLabel,
} from '@/features/user/home/applied-stores/types/appliedStore'
+import type { ApplicationApiStatus } from '@/shared/types/applicationStatus'
// ---- API Status ----
-export type ApplicationApiStatus =
- | 'SUBMITTED'
- | 'SHORTLISTED'
- | 'ACCEPTED'
- | 'REJECTED'
- | 'CANCELLED'
- | 'EXPIRED'
- | 'DELETED'
+// 크로스 롤(유저·매니저) 공용 enum — 정의는 shared로 이동, 기존 소비처를 위해 재노출
+export type { ApplicationApiStatus } from '@/shared/types/applicationStatus'
// ---- DTO ----
export interface PostingScheduleDto {
diff --git a/src/shared/types/applicationStatus.ts b/src/shared/types/applicationStatus.ts
new file mode 100644
index 00000000..34be0c5c
--- /dev/null
+++ b/src/shared/types/applicationStatus.ts
@@ -0,0 +1,12 @@
+/**
+ * 공고 지원서 상태 — 서버 enum.
+ * 유저(내 지원 현황)와 매니저(지원자 관리) 양쪽에서 사용하는 크로스 롤 타입입니다.
+ */
+export type ApplicationApiStatus =
+ | 'SUBMITTED'
+ | 'SHORTLISTED'
+ | 'ACCEPTED'
+ | 'REJECTED'
+ | 'CANCELLED'
+ | 'EXPIRED'
+ | 'DELETED'
From c84bcbda4e1b96b151a99ff8cc73c1c49e22a325 Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Wed, 29 Jul 2026 20:10:54 +0900
Subject: [PATCH 19/33] =?UTF-8?q?refactor:=20=EC=BB=A4=EC=84=9C=20?=
=?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=95=C2=B7=EC=8B=9C=EA=B0=84=20=ED=8F=AC?=
=?UTF-8?q?=EB=A7=B7=20=EA=B3=B5=EC=9A=A9=20=EC=9C=A0=ED=8B=B8=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/shared/lib/cursorPage.ts | 19 +++++++++++++++++++
src/shared/lib/toTimeOfDay.ts | 4 ++++
2 files changed, 23 insertions(+)
create mode 100644 src/shared/lib/cursorPage.ts
create mode 100644 src/shared/lib/toTimeOfDay.ts
diff --git a/src/shared/lib/cursorPage.ts b/src/shared/lib/cursorPage.ts
new file mode 100644
index 00000000..539e9513
--- /dev/null
+++ b/src/shared/lib/cursorPage.ts
@@ -0,0 +1,19 @@
+import type { CommonApiResponse } from '@/shared/types/common'
+
+export interface CursorPageInfo {
+ cursor: string | null
+ pageSize: number
+ totalCount: number
+}
+
+export interface CursorPage {
+ page: CursorPageInfo
+ data: T[]
+}
+
+export function unwrapCursorPage(
+ body: CursorPage | CommonApiResponse>
+): CursorPage {
+ if (body && 'page' in body) return body
+ return body.data
+}
diff --git a/src/shared/lib/toTimeOfDay.ts b/src/shared/lib/toTimeOfDay.ts
new file mode 100644
index 00000000..e36fe667
--- /dev/null
+++ b/src/shared/lib/toTimeOfDay.ts
@@ -0,0 +1,4 @@
+export function toTimeOfDay(time: string | null | undefined): string {
+ if (!time) return ''
+ return time.slice(0, 5)
+}
From a38b286a923dc475dcd75583f76f4b3eb1f4ffa9 Mon Sep 17 00:00:00 2001
From: SeongHwan
Date: Wed, 29 Jul 2026 20:12:57 +0900
Subject: [PATCH 20/33] =?UTF-8?q?feat:=20=EB=A7=A4=EB=8B=88=EC=A0=80=20?=
=?UTF-8?q?=EA=B3=B5=EA=B3=A0=C2=B7=EC=A7=80=EC=9B=90=EC=9E=90=20API=20?=
=?UTF-8?q?=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EB=AA=A9=EC=97=85=20=EC=A0=9C?=
=?UTF-8?q?=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../manager/posting/api/application.ts | 57 ++++
src/features/manager/posting/api/posting.ts | 69 +++++
.../hooks/mutation/useClosePostingMutation.ts | 23 ++
.../mutation/useCreatePostingMutation.ts | 31 +++
.../useUpdateApplicationStatusMutation.ts | 27 ++
.../mutation/useUpdatePostingMutation.ts | 34 +++
.../query/useManagerPostingDetailQuery.ts | 22 ++
.../hooks/query/useManagerPostingsQuery.ts | 65 +++++
.../query/usePostingApplicationDetailQuery.ts | 23 ++
.../query/usePostingApplicationsQuery.ts | 65 +++++
.../hooks/query/useWorkspaceFilterOptions.ts | 58 ++++
.../hooks/useApplicationDetailViewModel.ts | 34 +--
.../hooks/useApplicationListViewModel.ts | 141 ++--------
.../hooks/usePostingDetailViewModel.ts | 33 +--
.../manager/posting/hooks/usePostingForm.ts | 18 +-
.../posting/hooks/usePostingListViewModel.ts | 74 ++---
.../posting/lib/buildPostingRequest.ts | 75 ++++++
.../posting/lib/postingErrorMessage.ts | 26 ++
.../manager/posting/lib/postingStatus.ts | 14 +-
src/features/manager/posting/mocks/data.ts | 249 -----------------
src/features/manager/posting/mocks/store.ts | 120 ---------
src/features/manager/posting/types/dto.ts | 255 ++++++++++++++++++
src/features/manager/posting/types/posting.ts | 57 ++--
.../manager/posting/ui/ApplicantCard.tsx | 15 +-
src/features/manager/posting/ui/FilterBar.tsx | 3 +-
.../manager/posting/ui/PostingFormFields.tsx | 19 +-
.../manager/posting/ui/PostingListCard.tsx | 27 +-
.../manager/application-detail/index.tsx | 19 +-
src/pages/manager/application-list/index.tsx | 26 +-
src/pages/manager/posting-create/index.tsx | 29 +-
src/pages/manager/posting-detail/index.tsx | 30 ++-
src/pages/manager/posting-edit/index.tsx | 68 +++--
src/pages/manager/posting-list/index.tsx | 71 ++++-
src/shared/lib/queryKeys.ts | 9 +
34 files changed, 1165 insertions(+), 721 deletions(-)
create mode 100644 src/features/manager/posting/api/application.ts
create mode 100644 src/features/manager/posting/api/posting.ts
create mode 100644 src/features/manager/posting/hooks/mutation/useClosePostingMutation.ts
create mode 100644 src/features/manager/posting/hooks/mutation/useCreatePostingMutation.ts
create mode 100644 src/features/manager/posting/hooks/mutation/useUpdateApplicationStatusMutation.ts
create mode 100644 src/features/manager/posting/hooks/mutation/useUpdatePostingMutation.ts
create mode 100644 src/features/manager/posting/hooks/query/useManagerPostingDetailQuery.ts
create mode 100644 src/features/manager/posting/hooks/query/useManagerPostingsQuery.ts
create mode 100644 src/features/manager/posting/hooks/query/usePostingApplicationDetailQuery.ts
create mode 100644 src/features/manager/posting/hooks/query/usePostingApplicationsQuery.ts
create mode 100644 src/features/manager/posting/hooks/query/useWorkspaceFilterOptions.ts
create mode 100644 src/features/manager/posting/lib/buildPostingRequest.ts
create mode 100644 src/features/manager/posting/lib/postingErrorMessage.ts
delete mode 100644 src/features/manager/posting/mocks/data.ts
delete mode 100644 src/features/manager/posting/mocks/store.ts
create mode 100644 src/features/manager/posting/types/dto.ts
diff --git a/src/features/manager/posting/api/application.ts b/src/features/manager/posting/api/application.ts
new file mode 100644
index 00000000..2d3f685a
--- /dev/null
+++ b/src/features/manager/posting/api/application.ts
@@ -0,0 +1,57 @@
+import axiosInstance from '@/shared/lib/axiosInstance'
+import { unwrapCursorPage, type CursorPage } from '@/shared/lib/cursorPage'
+import type { ApplicationApiStatus } from '@/shared/types/applicationStatus'
+import type { CommonApiResponse } from '@/shared/types/common'
+import type {
+ PostingApplicationDetailDto,
+ PostingApplicationListItemDto,
+} from '@/features/manager/posting/types/dto'
+
+export interface PostingApplicationsQueryParams {
+ pageSize: number
+ workspaceId?: number
+ status?: ApplicationApiStatus[]
+ cursor?: string
+}
+
+type ApplicationListBody =
+ | CursorPage
+ | CommonApiResponse>
+
+export async function fetchPostingApplications(
+ params: PostingApplicationsQueryParams
+): Promise> {
+ const response = await axiosInstance.get(
+ '/manager/postings/applications',
+ {
+ params: {
+ pageSize: params.pageSize,
+ ...(params.workspaceId !== undefined && {
+ workspaceId: params.workspaceId,
+ }),
+ ...(params.status?.length && { status: params.status }),
+ ...(params.cursor !== undefined && { cursor: params.cursor }),
+ },
+ }
+ )
+ return unwrapCursorPage(response.data)
+}
+
+export async function fetchPostingApplicationDetail(
+ postingApplicationId: number
+): Promise {
+ const response = await axiosInstance.get<
+ CommonApiResponse
+ >(`/manager/postings/applications/${postingApplicationId}`)
+ return response.data.data
+}
+
+export async function patchPostingApplicationStatus(
+ postingApplicationId: number,
+ status: ApplicationApiStatus
+): Promise {
+ await axiosInstance.patch(
+ `/manager/postings/applications/${postingApplicationId}/status`,
+ { status }
+ )
+}
diff --git a/src/features/manager/posting/api/posting.ts b/src/features/manager/posting/api/posting.ts
new file mode 100644
index 00000000..a07e9b34
--- /dev/null
+++ b/src/features/manager/posting/api/posting.ts
@@ -0,0 +1,69 @@
+import axiosInstance from '@/shared/lib/axiosInstance'
+import { unwrapCursorPage, type CursorPage } from '@/shared/lib/cursorPage'
+import type { CommonApiResponse } from '@/shared/types/common'
+import type { PostingStatus } from '@/features/manager/posting/types/posting'
+import type {
+ CreatePostingRequestDto,
+ ManagerPostingDetailDto,
+ ManagerPostingListItemDto,
+ UpdatePostingRequestDto,
+} from '@/features/manager/posting/types/dto'
+
+export interface ManagerPostingsQueryParams {
+ pageSize: number
+ workspaceId?: number
+ status?: PostingStatus
+ cursor?: string
+}
+
+type PostingListBody =
+ | CursorPage
+ | CommonApiResponse>
+
+export async function fetchManagerPostings(
+ params: ManagerPostingsQueryParams
+): Promise> {
+ const response = await axiosInstance.get(
+ '/manager/postings',
+ {
+ params: {
+ pageSize: params.pageSize,
+ ...(params.workspaceId !== undefined && {
+ workspaceId: params.workspaceId,
+ }),
+ ...(params.status && { status: params.status }),
+ ...(params.cursor !== undefined && { cursor: params.cursor }),
+ },
+ }
+ )
+ return unwrapCursorPage(response.data)
+}
+
+export async function fetchManagerPostingDetail(
+ postingId: number
+): Promise {
+ const response = await axiosInstance.get<
+ CommonApiResponse
+ >(`/manager/postings/${postingId}`)
+ return response.data.data
+}
+
+export async function postManagerPosting(
+ body: CreatePostingRequestDto
+): Promise {
+ await axiosInstance.post('/manager/postings', body)
+}
+
+export async function putManagerPosting(
+ postingId: number,
+ body: UpdatePostingRequestDto
+): Promise {
+ await axiosInstance.put(`/manager/postings/${postingId}`, body)
+}
+
+export async function patchManagerPostingStatus(
+ postingId: number,
+ status: PostingStatus
+): Promise {
+ await axiosInstance.patch(`/manager/postings/${postingId}/status`, { status })
+}
diff --git a/src/features/manager/posting/hooks/mutation/useClosePostingMutation.ts b/src/features/manager/posting/hooks/mutation/useClosePostingMutation.ts
new file mode 100644
index 00000000..ed4a5e77
--- /dev/null
+++ b/src/features/manager/posting/hooks/mutation/useClosePostingMutation.ts
@@ -0,0 +1,23 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+import { patchManagerPostingStatus } from '@/features/manager/posting/api/posting'
+import { resolvePostingErrorMessage } from '@/features/manager/posting/lib/postingErrorMessage'
+import { queryKeys } from '@/shared/lib/queryKeys'
+import { showToast } from '@/shared/stores/useToastStore'
+
+export function useClosePostingMutation(postingId: number) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: () => patchManagerPostingStatus(postingId, 'CLOSED'),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.posting.all })
+ },
+ onError: (error: unknown) => {
+ showToast(
+ resolvePostingErrorMessage(error, '모집을 마감하지 못했어요.'),
+ 'error'
+ )
+ },
+ })
+}
diff --git a/src/features/manager/posting/hooks/mutation/useCreatePostingMutation.ts b/src/features/manager/posting/hooks/mutation/useCreatePostingMutation.ts
new file mode 100644
index 00000000..974b99e0
--- /dev/null
+++ b/src/features/manager/posting/hooks/mutation/useCreatePostingMutation.ts
@@ -0,0 +1,31 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+import { postManagerPosting } from '@/features/manager/posting/api/posting'
+import { toCreatePostingRequest } from '@/features/manager/posting/lib/buildPostingRequest'
+import { resolvePostingErrorMessage } from '@/features/manager/posting/lib/postingErrorMessage'
+import type { PostingFormValues } from '@/features/manager/posting/types/posting'
+import { queryKeys } from '@/shared/lib/queryKeys'
+import { showToast } from '@/shared/stores/useToastStore'
+
+interface CreatePostingVariables {
+ values: PostingFormValues
+ workspaceId: number
+}
+
+export function useCreatePostingMutation() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: ({ values, workspaceId }: CreatePostingVariables) =>
+ postManagerPosting(toCreatePostingRequest(values, workspaceId)),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.posting.all })
+ },
+ onError: (error: unknown) => {
+ showToast(
+ resolvePostingErrorMessage(error, '공고를 등록하지 못했어요.'),
+ 'error'
+ )
+ },
+ })
+}
diff --git a/src/features/manager/posting/hooks/mutation/useUpdateApplicationStatusMutation.ts b/src/features/manager/posting/hooks/mutation/useUpdateApplicationStatusMutation.ts
new file mode 100644
index 00000000..6db5faa7
--- /dev/null
+++ b/src/features/manager/posting/hooks/mutation/useUpdateApplicationStatusMutation.ts
@@ -0,0 +1,27 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+import { patchPostingApplicationStatus } from '@/features/manager/posting/api/application'
+import type { HiringDecision } from '@/features/manager/posting/lib/applicationStatus'
+import { resolvePostingErrorMessage } from '@/features/manager/posting/lib/postingErrorMessage'
+import { queryKeys } from '@/shared/lib/queryKeys'
+import { showToast } from '@/shared/stores/useToastStore'
+
+export function useUpdateApplicationStatusMutation(
+ postingApplicationId: number
+) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (status: HiringDecision) =>
+ patchPostingApplicationStatus(postingApplicationId, status),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.posting.all })
+ },
+ onError: (error: unknown) => {
+ showToast(
+ resolvePostingErrorMessage(error, '상태를 변경하지 못했어요.'),
+ 'error'
+ )
+ },
+ })
+}
diff --git a/src/features/manager/posting/hooks/mutation/useUpdatePostingMutation.ts b/src/features/manager/posting/hooks/mutation/useUpdatePostingMutation.ts
new file mode 100644
index 00000000..6eb64950
--- /dev/null
+++ b/src/features/manager/posting/hooks/mutation/useUpdatePostingMutation.ts
@@ -0,0 +1,34 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+import { putManagerPosting } from '@/features/manager/posting/api/posting'
+import { toUpdatePostingRequest } from '@/features/manager/posting/lib/buildPostingRequest'
+import { resolvePostingErrorMessage } from '@/features/manager/posting/lib/postingErrorMessage'
+import type { PostingFormValues } from '@/features/manager/posting/types/posting'
+import { queryKeys } from '@/shared/lib/queryKeys'
+import { showToast } from '@/shared/stores/useToastStore'
+
+interface UpdatePostingVariables {
+ values: PostingFormValues
+ originalScheduleIds: number[]
+}
+
+export function useUpdatePostingMutation(postingId: number) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: ({ values, originalScheduleIds }: UpdatePostingVariables) =>
+ putManagerPosting(
+ postingId,
+ toUpdatePostingRequest(values, originalScheduleIds)
+ ),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.posting.all })
+ },
+ onError: (error: unknown) => {
+ showToast(
+ resolvePostingErrorMessage(error, '공고를 수정하지 못했어요.'),
+ 'error'
+ )
+ },
+ })
+}
diff --git a/src/features/manager/posting/hooks/query/useManagerPostingDetailQuery.ts b/src/features/manager/posting/hooks/query/useManagerPostingDetailQuery.ts
new file mode 100644
index 00000000..721b1ca7
--- /dev/null
+++ b/src/features/manager/posting/hooks/query/useManagerPostingDetailQuery.ts
@@ -0,0 +1,22 @@
+import { useQuery } from '@tanstack/react-query'
+
+import { fetchManagerPostingDetail } from '@/features/manager/posting/api/posting'
+import { adaptPostingDetail } from '@/features/manager/posting/types/dto'
+import { queryKeys } from '@/shared/lib/queryKeys'
+
+export function useManagerPostingDetailQuery(postingId: number) {
+ const isValidId = Number.isInteger(postingId) && postingId > 0
+
+ const { data, isPending, isError } = useQuery({
+ queryKey: queryKeys.posting.detail(postingId),
+ queryFn: () => fetchManagerPostingDetail(postingId),
+ enabled: isValidId,
+ select: adaptPostingDetail,
+ })
+
+ return {
+ posting: data ?? null,
+ isLoading: isValidId && isPending,
+ isError: !isValidId || isError,
+ }
+}
diff --git a/src/features/manager/posting/hooks/query/useManagerPostingsQuery.ts b/src/features/manager/posting/hooks/query/useManagerPostingsQuery.ts
new file mode 100644
index 00000000..407583dc
--- /dev/null
+++ b/src/features/manager/posting/hooks/query/useManagerPostingsQuery.ts
@@ -0,0 +1,65 @@
+import { useMemo } from 'react'
+import { useInfiniteQuery } from '@tanstack/react-query'
+
+import { fetchManagerPostings } from '@/features/manager/posting/api/posting'
+import { adaptPostingListItem } from '@/features/manager/posting/types/dto'
+import type {
+ PostingListItem,
+ PostingStatus,
+} from '@/features/manager/posting/types/posting'
+import { queryKeys } from '@/shared/lib/queryKeys'
+
+const PAGE_SIZE = 10
+
+interface UseManagerPostingsQueryOptions {
+ workspaceId?: number
+ status?: PostingStatus
+}
+
+export function useManagerPostingsQuery({
+ workspaceId,
+ status,
+}: UseManagerPostingsQueryOptions) {
+ const {
+ data,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isPending,
+ isError,
+ } = useInfiniteQuery({
+ queryKey: queryKeys.posting.list({
+ workspaceId,
+ status,
+ pageSize: PAGE_SIZE,
+ }),
+ queryFn: ({ pageParam }) =>
+ fetchManagerPostings({
+ pageSize: PAGE_SIZE,
+ workspaceId,
+ status,
+ cursor: pageParam,
+ }),
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: lastPage => lastPage.page?.cursor ?? undefined,
+ })
+
+ const postings = useMemo(() => {
+ const byId = new Map()
+ for (const dto of data?.pages.flatMap(page => page.data ?? []) ?? []) {
+ const posting = adaptPostingListItem(dto)
+ byId.set(posting.id, posting)
+ }
+ return [...byId.values()]
+ }, [data])
+
+ return {
+ postings,
+ totalCount: data?.pages[0]?.page?.totalCount ?? postings.length,
+ isLoading: isPending,
+ isError,
+ hasNextPage: Boolean(hasNextPage),
+ isFetchingNextPage,
+ fetchNextPage,
+ }
+}
diff --git a/src/features/manager/posting/hooks/query/usePostingApplicationDetailQuery.ts b/src/features/manager/posting/hooks/query/usePostingApplicationDetailQuery.ts
new file mode 100644
index 00000000..827fa8a8
--- /dev/null
+++ b/src/features/manager/posting/hooks/query/usePostingApplicationDetailQuery.ts
@@ -0,0 +1,23 @@
+import { useQuery } from '@tanstack/react-query'
+
+import { fetchPostingApplicationDetail } from '@/features/manager/posting/api/application'
+import { adaptApplicationDetail } from '@/features/manager/posting/types/dto'
+import { queryKeys } from '@/shared/lib/queryKeys'
+
+export function usePostingApplicationDetailQuery(postingApplicationId: number) {
+ const isValidId =
+ Number.isInteger(postingApplicationId) && postingApplicationId > 0
+
+ const { data, isPending, isError } = useQuery({
+ queryKey: queryKeys.posting.applicationDetail(postingApplicationId),
+ queryFn: () => fetchPostingApplicationDetail(postingApplicationId),
+ enabled: isValidId,
+ select: adaptApplicationDetail,
+ })
+
+ return {
+ application: data ?? null,
+ isLoading: isValidId && isPending,
+ isError: !isValidId || isError,
+ }
+}
diff --git a/src/features/manager/posting/hooks/query/usePostingApplicationsQuery.ts b/src/features/manager/posting/hooks/query/usePostingApplicationsQuery.ts
new file mode 100644
index 00000000..568c2e9c
--- /dev/null
+++ b/src/features/manager/posting/hooks/query/usePostingApplicationsQuery.ts
@@ -0,0 +1,65 @@
+import { useMemo } from 'react'
+import { useInfiniteQuery } from '@tanstack/react-query'
+
+import { fetchPostingApplications } from '@/features/manager/posting/api/application'
+import { adaptApplicationListItem } from '@/features/manager/posting/types/dto'
+import type { ApplicationListItem } from '@/features/manager/posting/types/posting'
+import type { ApplicationApiStatus } from '@/shared/types/applicationStatus'
+import { queryKeys } from '@/shared/lib/queryKeys'
+
+const PAGE_SIZE = 10
+
+interface UsePostingApplicationsQueryOptions {
+ workspaceId?: number
+ status?: ApplicationApiStatus
+}
+
+export function usePostingApplicationsQuery({
+ workspaceId,
+ status,
+}: UsePostingApplicationsQueryOptions) {
+ const statusFilter = useMemo(() => (status ? [status] : undefined), [status])
+
+ const {
+ data,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isPending,
+ isError,
+ } = useInfiniteQuery({
+ queryKey: queryKeys.posting.applicationList({
+ workspaceId,
+ status: statusFilter,
+ pageSize: PAGE_SIZE,
+ }),
+ queryFn: ({ pageParam }) =>
+ fetchPostingApplications({
+ pageSize: PAGE_SIZE,
+ workspaceId,
+ status: statusFilter,
+ cursor: pageParam,
+ }),
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: lastPage => lastPage.page?.cursor ?? undefined,
+ })
+
+ const applications = useMemo(() => {
+ const byId = new Map()
+ for (const dto of data?.pages.flatMap(page => page.data ?? []) ?? []) {
+ const application = adaptApplicationListItem(dto)
+ byId.set(application.id, application)
+ }
+ return [...byId.values()]
+ }, [data])
+
+ return {
+ applications,
+ totalCount: data?.pages[0]?.page?.totalCount ?? applications.length,
+ isLoading: isPending,
+ isError,
+ hasNextPage: Boolean(hasNextPage),
+ isFetchingNextPage,
+ fetchNextPage,
+ }
+}
diff --git a/src/features/manager/posting/hooks/query/useWorkspaceFilterOptions.ts b/src/features/manager/posting/hooks/query/useWorkspaceFilterOptions.ts
new file mode 100644
index 00000000..1c2fcb76
--- /dev/null
+++ b/src/features/manager/posting/hooks/query/useWorkspaceFilterOptions.ts
@@ -0,0 +1,58 @@
+import { useMemo } from 'react'
+import { useQuery } from '@tanstack/react-query'
+
+import { fetchManagedWorkspaces } from '@/features/manager/api/workspace'
+import { queryKeys } from '@/shared/lib/queryKeys'
+import type { SelectOption } from '@/shared/ui/common/SelectDropdown'
+
+export const ALL_WORKSPACES = 'ALL' as const
+export type WorkspaceFilter = number | typeof ALL_WORKSPACES
+
+function useActivatedWorkspaces() {
+ const { data, isPending, isError } = useQuery({
+ queryKey: queryKeys.managerWorkspace.list(),
+ queryFn: fetchManagedWorkspaces,
+ })
+
+ const workspaces = useMemo(
+ () =>
+ (data?.data ?? []).filter(
+ workspace => workspace.status?.value === 'ACTIVATED'
+ ),
+ [data]
+ )
+
+ return { workspaces, isLoading: isPending, isError }
+}
+
+export function useWorkspaceFilterOptions() {
+ const { workspaces, isLoading, isError } = useActivatedWorkspaces()
+
+ const workspaceOptions = useMemo[]>(
+ () => [
+ { value: ALL_WORKSPACES, label: '전체 업장' },
+ ...workspaces.map(workspace => ({
+ value: workspace.id as WorkspaceFilter,
+ label: workspace.businessName,
+ })),
+ ],
+ [workspaces]
+ )
+
+ return { workspaceOptions, isLoading, isError }
+}
+
+export function useWorkspaceSelectOptions() {
+ const { workspaces, isLoading, isError } = useActivatedWorkspaces()
+
+ const workspaceOptions = useMemo[]>(
+ () =>
+ workspaces.map(workspace => ({
+ value: workspace.id,
+ label: workspace.businessName,
+ })),
+ [workspaces]
+ )
+
+ return { workspaceOptions, isLoading, isError }
+}
diff --git a/src/features/manager/posting/hooks/useApplicationDetailViewModel.ts b/src/features/manager/posting/hooks/useApplicationDetailViewModel.ts
index cd08c19d..5c878268 100644
--- a/src/features/manager/posting/hooks/useApplicationDetailViewModel.ts
+++ b/src/features/manager/posting/hooks/useApplicationDetailViewModel.ts
@@ -1,48 +1,48 @@
-import { useMemo, useState } from 'react'
+import { useState } from 'react'
import {
DECISION_COPY,
isTerminalApplicationStatus,
type HiringDecision,
} from '@/features/manager/posting/lib/applicationStatus'
-import { useMockPostingStore } from '@/features/manager/posting/mocks/store'
+import { useUpdateApplicationStatusMutation } from '@/features/manager/posting/hooks/mutation/useUpdateApplicationStatusMutation'
+import { usePostingApplicationDetailQuery } from '@/features/manager/posting/hooks/query/usePostingApplicationDetailQuery'
import { showToast } from '@/shared/stores/useToastStore'
-/** 지원자 상세 — 인적사항 조회 + 채용 결정(서류합격·최종합격·불합격) */
export function useApplicationDetailViewModel(applicationId: number) {
- const applications = useMockPostingStore(state => state.applications)
- const updateApplicationStatus = useMockPostingStore(
- state => state.updateApplicationStatus
- )
+ const { application, isLoading, isError } =
+ usePostingApplicationDetailQuery(applicationId)
+ const updateStatus = useUpdateApplicationStatusMutation(applicationId)
const [pendingDecision, setPendingDecision] = useState(
null
)
- const application = useMemo(
- () => applications.find(item => item.id === applicationId) ?? null,
- [applications, applicationId]
- )
-
const isTerminal = application
? isTerminalApplicationStatus(application.status)
: false
const confirmDecision = () => {
if (!pendingDecision) return
- updateApplicationStatus(applicationId, pendingDecision)
- showToast(DECISION_COPY[pendingDecision].toast)
+ const decision = pendingDecision
setPendingDecision(null)
+ updateStatus.mutate(decision, {
+ onSuccess: () => showToast(DECISION_COPY[decision].toast),
+ })
}
return {
application,
- isNotFound: application === null,
- /** 종료된 지원서는 채용 결정 액션을 숨깁니다 */
+ isLoading,
+ isNotFound: !isLoading && (isError || application === null),
canDecide: application !== null && !isTerminal,
+ isDeciding: updateStatus.isPending,
pendingDecision,
decisionCopy: pendingDecision ? DECISION_COPY[pendingDecision] : null,
- requestDecision: (decision: HiringDecision) => setPendingDecision(decision),
+ requestDecision: (decision: HiringDecision) => {
+ if (updateStatus.isPending) return
+ setPendingDecision(decision)
+ },
cancelDecision: () => setPendingDecision(null),
confirmDecision,
}
diff --git a/src/features/manager/posting/hooks/useApplicationListViewModel.ts b/src/features/manager/posting/hooks/useApplicationListViewModel.ts
index d8f30246..de191103 100644
--- a/src/features/manager/posting/hooks/useApplicationListViewModel.ts
+++ b/src/features/manager/posting/hooks/useApplicationListViewModel.ts
@@ -1,129 +1,42 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useState } from 'react'
-import type { ApplicationStatusFilter } from '@/features/manager/posting/lib/applicationStatus'
-import { MOCK_WORKSPACES } from '@/features/manager/posting/mocks/data'
-import {
- MOCK_LATENCY,
- useMockPostingStore,
-} from '@/features/manager/posting/mocks/store'
+import { usePostingApplicationsQuery } from '@/features/manager/posting/hooks/query/usePostingApplicationsQuery'
import {
ALL_WORKSPACES,
+ useWorkspaceFilterOptions,
type WorkspaceFilter,
-} from '@/features/manager/posting/hooks/usePostingListViewModel'
-
-/** 커서 기반 무한스크롤 흉내 — 한 번에 노출할 개수 */
-const PAGE_SIZE = 4
-
-interface UseApplicationListOptions {
- /** 특정 공고의 지원자만 조회 (공고 상세 → 지원자 보기) */
- postingId?: number
-}
-
-/** 지원자 목록 — 업장·상태 필터 + 무한스크롤 */
-export function useApplicationListViewModel({
- postingId,
-}: UseApplicationListOptions = {}) {
- const applications = useMockPostingStore(state => state.applications)
+} from '@/features/manager/posting/hooks/query/useWorkspaceFilterOptions'
+import type { ApplicationStatusFilter } from '@/features/manager/posting/lib/applicationStatus'
- const [workspaceFilter, setWorkspaceFilterState] =
+// 서버에 postingId 필터가 없어 공고별로 좁힐 수 없음 (응답 DTO에도 postingId 없음)
+export function useApplicationListViewModel() {
+ const [workspaceFilter, setWorkspaceFilter] =
useState(ALL_WORKSPACES)
- const [statusFilter, setStatusFilterState] =
+ const [statusFilter, setStatusFilter] =
useState('ALL')
- const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
- const [isLoading, setIsLoading] = useState(true)
- const [isFetchingNextPage, setIsFetchingNextPage] = useState(false)
- /** 다음 페이지 로딩 타이머 — 언마운트·필터 변경 시 취소 */
- const nextPageTimer = useRef | null>(null)
-
- useEffect(() => {
- const timer = setTimeout(() => setIsLoading(false), MOCK_LATENCY)
- return () => clearTimeout(timer)
- }, [])
-
- useEffect(
- () => () => {
- if (nextPageTimer.current) clearTimeout(nextPageTimer.current)
- },
- []
- )
- /** 진행 중인 다음 페이지 로딩을 취소하고 첫 페이지로 되돌림 */
- const resetPaging = useCallback(() => {
- if (nextPageTimer.current) {
- clearTimeout(nextPageTimer.current)
- nextPageTimer.current = null
- }
- setIsFetchingNextPage(false)
- setVisibleCount(PAGE_SIZE)
- }, [])
+ const { workspaceOptions } = useWorkspaceFilterOptions()
- // 필터가 바뀌면 페이지를 처음으로 되돌림
- const setWorkspaceFilter = useCallback(
- (value: WorkspaceFilter) => {
- setWorkspaceFilterState(value)
- resetPaging()
- },
- [resetPaging]
- )
-
- const setStatusFilter = useCallback(
- (value: ApplicationStatusFilter) => {
- setStatusFilterState(value)
- resetPaging()
- },
- [resetPaging]
- )
-
- const filteredApplications = useMemo(
- () =>
- applications.filter(application => {
- if (postingId !== undefined && application.postingId !== postingId) {
- return false
- }
- if (
- workspaceFilter !== ALL_WORKSPACES &&
- application.workspaceId !== workspaceFilter
- ) {
- return false
- }
- if (statusFilter !== 'ALL' && application.status !== statusFilter) {
- return false
- }
- return true
- }),
- [applications, postingId, workspaceFilter, statusFilter]
- )
-
- const visibleApplications = filteredApplications.slice(0, visibleCount)
- const hasNextPage = visibleCount < filteredApplications.length
-
- /** 커서 기반 다음 페이지 로드 — 실제 API 연동 시 useInfiniteQuery로 교체 */
- const fetchNextPage = useCallback(() => {
- if (nextPageTimer.current) return
- setIsFetchingNextPage(true)
- nextPageTimer.current = setTimeout(() => {
- setVisibleCount(prev => prev + PAGE_SIZE)
- setIsFetchingNextPage(false)
- nextPageTimer.current = null
- }, MOCK_LATENCY)
- }, [])
-
- const workspaceOptions = useMemo(
- () => [
- { value: ALL_WORKSPACES as WorkspaceFilter, label: '전체 업장' },
- ...MOCK_WORKSPACES.map(workspace => ({
- value: workspace.id as WorkspaceFilter,
- label: workspace.businessName,
- })),
- ],
- []
- )
+ const {
+ applications,
+ totalCount,
+ isLoading,
+ isError,
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
+ } = usePostingApplicationsQuery({
+ workspaceId:
+ workspaceFilter === ALL_WORKSPACES ? undefined : workspaceFilter,
+ status: statusFilter === 'ALL' ? undefined : statusFilter,
+ })
return {
- applications: visibleApplications,
- totalCount: filteredApplications.length,
+ applications,
+ totalCount,
isLoading,
- isEmpty: !isLoading && filteredApplications.length === 0,
+ isError,
+ isEmpty: !isLoading && !isError && applications.length === 0,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
diff --git a/src/features/manager/posting/hooks/usePostingDetailViewModel.ts b/src/features/manager/posting/hooks/usePostingDetailViewModel.ts
index 2e8591f7..9a3d30a5 100644
--- a/src/features/manager/posting/hooks/usePostingDetailViewModel.ts
+++ b/src/features/manager/posting/hooks/usePostingDetailViewModel.ts
@@ -1,37 +1,28 @@
-import { useMemo, useState } from 'react'
+import { useState } from 'react'
-import { useMockPostingStore } from '@/features/manager/posting/mocks/store'
+import { useClosePostingMutation } from '@/features/manager/posting/hooks/mutation/useClosePostingMutation'
+import { useManagerPostingDetailQuery } from '@/features/manager/posting/hooks/query/useManagerPostingDetailQuery'
import { showToast } from '@/shared/stores/useToastStore'
-/** 공고 상세 — 조회 + 모집 마감 처리 */
export function usePostingDetailViewModel(postingId: number) {
- const postings = useMockPostingStore(state => state.postings)
- const applications = useMockPostingStore(state => state.applications)
- const closePosting = useMockPostingStore(state => state.closePosting)
+ const { posting, isLoading, isError } =
+ useManagerPostingDetailQuery(postingId)
+ const closePosting = useClosePostingMutation(postingId)
const [isCloseModalOpen, setIsCloseModalOpen] = useState(false)
- const posting = useMemo(
- () => postings.find(item => item.id === postingId) ?? null,
- [postings, postingId]
- )
-
- // 목업 지원자 수 — 실제 API에서는 공고 상세 응답 필드 사용
- const applicantCount = useMemo(
- () => applications.filter(item => item.postingId === postingId).length,
- [applications, postingId]
- )
-
const confirmClose = () => {
- closePosting(postingId)
setIsCloseModalOpen(false)
- showToast('모집을 마감했어요')
+ closePosting.mutate(undefined, {
+ onSuccess: () => showToast('모집을 마감했어요'),
+ })
}
return {
posting,
- applicantCount,
- isNotFound: posting === null,
+ isLoading,
+ isNotFound: !isLoading && (isError || posting === null),
+ isClosing: closePosting.isPending,
isCloseModalOpen,
openCloseModal: () => setIsCloseModalOpen(true),
closeCloseModal: () => setIsCloseModalOpen(false),
diff --git a/src/features/manager/posting/hooks/usePostingForm.ts b/src/features/manager/posting/hooks/usePostingForm.ts
index 2c09e712..16f923da 100644
--- a/src/features/manager/posting/hooks/usePostingForm.ts
+++ b/src/features/manager/posting/hooks/usePostingForm.ts
@@ -79,34 +79,28 @@ function validate(values: PostingFormValues): PostingFormErrors {
errors.payAmount = '급여를 입력해 주세요'
}
+ // 서버가 description을 필수(minLength 1)로 받습니다
+ if (values.description.trim() === '') {
+ errors.description = '상세내용을 입력해 주세요'
+ }
+
return errors
}
interface UsePostingFormOptions {
- /** 수정 모드일 때 프리필할 공고 */
posting?: Posting | null
}
-/**
- * 공고 등록·수정 공용 폼 view-model.
- * 프로젝트 관행에 따라 react-hook-form/zod 대신 useState 기반으로 구성합니다
- * (참고: features/store-register/hooks/useStoreRegisterWizard.ts).
- */
export function usePostingForm({ posting }: UsePostingFormOptions = {}) {
const isEditMode = Boolean(posting)
const [values, setValues] = useState(() =>
createInitialValues(posting)
)
- /** 제출을 한 번이라도 시도했을 때만 에러를 노출 */
const [isSubmitAttempted, setIsSubmitAttempted] = useState(false)
const errors = useMemo(() => validate(values), [values])
const isValid = Object.keys(errors).length === 0
const visibleErrors: PostingFormErrors = isSubmitAttempted ? errors : {}
- /**
- * 제출 전에는 버튼을 활성화해 두고, 클릭 시 검증 에러를 노출합니다.
- * (처음부터 비활성화하면 무엇이 비었는지 알 수 없어 막다른 길이 됩니다)
- */
const isSubmitDisabled = isSubmitAttempted && !isValid
const setWorkspaceId = useCallback((workspaceId: number) => {
@@ -122,7 +116,6 @@ export function usePostingForm({ posting }: UsePostingFormOptions = {}) {
}, [])
const setPayAmount = useCallback((payAmount: string) => {
- // 숫자만 허용 후 천 단위 구분 표시는 UI에서 처리
setValues(prev => ({
...prev,
payAmount: payAmount.replace(/[^0-9]/g, ''),
@@ -172,7 +165,6 @@ export function usePostingForm({ posting }: UsePostingFormOptions = {}) {
}))
}, [])
- /** 제출 시도 — 유효하면 true를 반환하고, 아니면 에러를 노출합니다 */
const attemptSubmit = useCallback(() => {
setIsSubmitAttempted(true)
return Object.keys(validate(values)).length === 0
diff --git a/src/features/manager/posting/hooks/usePostingListViewModel.ts b/src/features/manager/posting/hooks/usePostingListViewModel.ts
index 00148dbc..77e37992 100644
--- a/src/features/manager/posting/hooks/usePostingListViewModel.ts
+++ b/src/features/manager/posting/hooks/usePostingListViewModel.ts
@@ -1,63 +1,43 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useState } from 'react'
-import { MOCK_WORKSPACES } from '@/features/manager/posting/mocks/data'
+import { useManagerPostingsQuery } from '@/features/manager/posting/hooks/query/useManagerPostingsQuery'
import {
- MOCK_LATENCY,
- useMockPostingStore,
-} from '@/features/manager/posting/mocks/store'
+ ALL_WORKSPACES,
+ useWorkspaceFilterOptions,
+ type WorkspaceFilter,
+} from '@/features/manager/posting/hooks/query/useWorkspaceFilterOptions'
import type { PostingStatusFilter } from '@/features/manager/posting/lib/postingStatus'
-export const ALL_WORKSPACES = 'ALL' as const
-export type WorkspaceFilter = number | typeof ALL_WORKSPACES
-
-/** 내 공고 목록 — 업장·상태 필터 + 로딩(스켈레톤) 상태 */
export function usePostingListViewModel() {
- const postings = useMockPostingStore(state => state.postings)
-
const [workspaceFilter, setWorkspaceFilter] =
useState(ALL_WORKSPACES)
const [statusFilter, setStatusFilter] = useState('ALL')
- const [isLoading, setIsLoading] = useState(true)
- // 목업 로딩 지연 — 스켈레톤 확인용
- useEffect(() => {
- const timer = setTimeout(() => setIsLoading(false), MOCK_LATENCY)
- return () => clearTimeout(timer)
- }, [])
+ const { workspaceOptions } = useWorkspaceFilterOptions()
- const filteredPostings = useMemo(
- () =>
- postings.filter(posting => {
- if (
- workspaceFilter !== ALL_WORKSPACES &&
- posting.workspaceId !== workspaceFilter
- ) {
- return false
- }
- if (statusFilter !== 'ALL' && posting.status !== statusFilter) {
- return false
- }
- return true
- }),
- [postings, workspaceFilter, statusFilter]
- )
-
- const workspaceOptions = useMemo(
- () => [
- { value: ALL_WORKSPACES as WorkspaceFilter, label: '전체 업장' },
- ...MOCK_WORKSPACES.map(workspace => ({
- value: workspace.id as WorkspaceFilter,
- label: workspace.businessName,
- })),
- ],
- []
- )
+ const {
+ postings,
+ totalCount,
+ isLoading,
+ isError,
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
+ } = useManagerPostingsQuery({
+ workspaceId:
+ workspaceFilter === ALL_WORKSPACES ? undefined : workspaceFilter,
+ status: statusFilter === 'ALL' ? undefined : statusFilter,
+ })
return {
- postings: filteredPostings,
- totalCount: filteredPostings.length,
+ postings,
+ totalCount,
isLoading,
- isEmpty: !isLoading && filteredPostings.length === 0,
+ isError,
+ isEmpty: !isLoading && !isError && postings.length === 0,
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
workspaceFilter,
setWorkspaceFilter,
statusFilter,
diff --git a/src/features/manager/posting/lib/buildPostingRequest.ts b/src/features/manager/posting/lib/buildPostingRequest.ts
new file mode 100644
index 00000000..94f76e5c
--- /dev/null
+++ b/src/features/manager/posting/lib/buildPostingRequest.ts
@@ -0,0 +1,75 @@
+import type {
+ CreatePostingRequestDto,
+ CreatePostingScheduleRequestDto,
+ UpdatePostingRequestDto,
+ UpdatePostingScheduleRequestDto,
+} from '@/features/manager/posting/types/dto'
+import type {
+ PostingFormSchedule,
+ PostingFormValues,
+} from '@/features/manager/posting/types/posting'
+
+export function toPayAmount(payAmount: string): number {
+ return Number(payAmount.replace(/[^0-9]/g, '')) || 0
+}
+
+function toScheduleBody(
+ schedule: PostingFormSchedule
+): CreatePostingScheduleRequestDto {
+ const position = schedule.position.trim()
+ return {
+ workingDays: schedule.workingDays,
+ startTime: schedule.startTime,
+ endTime: schedule.endTime,
+ positionsNeeded: Math.max(1, schedule.positionsNeeded),
+ // 서버가 빈 문자열을 거부함
+ ...(position && { position }),
+ }
+}
+
+export function toCreatePostingRequest(
+ values: PostingFormValues,
+ workspaceId: number
+): CreatePostingRequestDto {
+ return {
+ workspaceId,
+ title: values.title.trim(),
+ description: values.description.trim(),
+ payAmount: toPayAmount(values.payAmount),
+ paymentType: values.paymentType,
+ schedules: values.schedules.map(toScheduleBody),
+ }
+}
+
+export function toUpdatePostingRequest(
+ values: PostingFormValues,
+ originalScheduleIds: number[]
+): UpdatePostingRequestDto {
+ const createSchedules: CreatePostingScheduleRequestDto[] = []
+ const updateSchedules: UpdatePostingScheduleRequestDto[] = []
+
+ for (const schedule of values.schedules) {
+ const body = toScheduleBody(schedule)
+ if (schedule.id === null) {
+ createSchedules.push(body)
+ } else {
+ updateSchedules.push({ ...body, id: schedule.id })
+ }
+ }
+
+ const remainingIds = new Set(
+ values.schedules
+ .map(schedule => schedule.id)
+ .filter((id): id is number => id !== null)
+ )
+
+ return {
+ title: values.title.trim(),
+ description: values.description.trim(),
+ payAmount: toPayAmount(values.payAmount),
+ paymentType: values.paymentType,
+ createSchedules,
+ updateSchedules,
+ deleteScheduleIds: originalScheduleIds.filter(id => !remainingIds.has(id)),
+ }
+}
diff --git a/src/features/manager/posting/lib/postingErrorMessage.ts b/src/features/manager/posting/lib/postingErrorMessage.ts
new file mode 100644
index 00000000..b1128bfe
--- /dev/null
+++ b/src/features/manager/posting/lib/postingErrorMessage.ts
@@ -0,0 +1,26 @@
+import axios from 'axios'
+
+import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage'
+import type { ErrorResponse } from '@/shared/types/common'
+
+const POSTING_ERROR_MESSAGES: Record = {
+ B007: '존재하지 않는 공고예요.',
+ B012: '지원 정보를 찾을 수 없어요.',
+ B017: '이미 처리된 지원서예요.',
+ B018: '이미 근무 중인 사용자예요.',
+ B019: '수정할 근무일정을 찾을 수 없어요.',
+ B020: '상태를 변경할 수 없는 공고예요.',
+}
+
+export function resolvePostingErrorMessage(
+ error: unknown,
+ fallback: string
+): string {
+ if (axios.isAxiosError(error)) {
+ const code = (error.response?.data as ErrorResponse | undefined)?.code
+ if (code && POSTING_ERROR_MESSAGES[code]) {
+ return POSTING_ERROR_MESSAGES[code]
+ }
+ }
+ return getAxiosErrorMessage(error, fallback)
+}
diff --git a/src/features/manager/posting/lib/postingStatus.ts b/src/features/manager/posting/lib/postingStatus.ts
index b32b4742..44dd425d 100644
--- a/src/features/manager/posting/lib/postingStatus.ts
+++ b/src/features/manager/posting/lib/postingStatus.ts
@@ -6,10 +6,6 @@ interface PostingStatusBadgeStyle {
textClassName: string
}
-/**
- * 공고 상태 배지 스타일.
- * PDF 기준: 마감임박(warning)은 이번 범위에서 미사용 — API에 마감일 필드 근거 없음.
- */
const POSTING_STATUS_BADGE: Record = {
OPEN: {
label: '모집중',
@@ -21,6 +17,16 @@ const POSTING_STATUS_BADGE: Record = {
containerClassName: 'bg-bg-dark',
textClassName: 'text-text-70',
},
+ CANCELLED: {
+ label: '취소됨',
+ containerClassName: 'bg-bg-dark',
+ textClassName: 'text-text-50',
+ },
+ DELETED: {
+ label: '삭제됨',
+ containerClassName: 'bg-bg-dark',
+ textClassName: 'text-text-50',
+ },
}
export function resolvePostingStatusBadge(status: PostingStatus) {
diff --git a/src/features/manager/posting/mocks/data.ts b/src/features/manager/posting/mocks/data.ts
deleted file mode 100644
index 09178264..00000000
--- a/src/features/manager/posting/mocks/data.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-/**
- * API 미연동 단계용 목업 데이터.
- * 추후 `features/manager/posting/api/`의 axios 호출로 교체됩니다.
- */
-import type {
- Application,
- Posting,
- Workspace,
-} from '@/features/manager/posting/types/posting'
-
-export const MOCK_WORKSPACES: Workspace[] = [
- { id: 1, businessName: '알터 강남점', businessType: '카페' },
- { id: 2, businessName: '알터 성수 로스터리', businessType: '카페' },
-]
-
-export const MOCK_POSTINGS: Posting[] = [
- {
- id: 1,
- workspaceId: 1,
- workspaceName: '알터 강남점',
- businessType: '카페',
- title: '주말 홀서빙 · 오후 마감조 구합니다',
- description:
- '주말 오후 마감조 홀서빙 아르바이트를 구합니다. 성실하게 근무하실 분 환영해요. 경험자 우대하며, 미경험자도 교육 후 근무 가능합니다.',
- paymentType: 'HOURLY',
- payAmount: 12000,
- status: 'OPEN',
- applicantCount: 6,
- createdAt: '2026-07-20T09:00:00.000Z',
- schedules: [
- {
- id: 101,
- workingDays: ['SATURDAY', 'SUNDAY'],
- startTime: '17:00',
- endTime: '22:00',
- position: '홀서빙',
- positionsNeeded: 2,
- },
- {
- id: 102,
- workingDays: ['FRIDAY', 'SATURDAY'],
- startTime: '21:00',
- endTime: '23:00',
- position: '마감 청소',
- positionsNeeded: 1,
- },
- ],
- },
- {
- id: 2,
- workspaceId: 1,
- workspaceName: '알터 강남점',
- businessType: '카페',
- title: '평일 오픈 바리스타',
- description:
- '평일 오픈조 바리스타를 모집합니다. 바리스타 자격증 보유자 우대합니다.',
- paymentType: 'HOURLY',
- payAmount: 12500,
- status: 'OPEN',
- applicantCount: 3,
- createdAt: '2026-07-18T09:00:00.000Z',
- schedules: [
- {
- id: 201,
- workingDays: ['MONDAY', 'WEDNESDAY', 'FRIDAY'],
- startTime: '07:00',
- endTime: '12:00',
- position: '바리스타',
- positionsNeeded: 1,
- },
- ],
- },
- {
- id: 3,
- workspaceId: 2,
- workspaceName: '알터 성수 로스터리',
- businessType: '카페',
- title: '여름 성수기 주말 단기',
- description: '여름 성수기 주말 단기 아르바이트를 모집합니다.',
- paymentType: 'DAILY',
- payAmount: 90000,
- status: 'CLOSED',
- applicantCount: 4,
- createdAt: '2026-07-05T09:00:00.000Z',
- schedules: [
- {
- id: 301,
- workingDays: ['SATURDAY', 'SUNDAY'],
- startTime: '13:00',
- endTime: '18:00',
- position: '베이킹 보조',
- positionsNeeded: 2,
- },
- ],
- },
-]
-
-export const MOCK_APPLICATIONS: Application[] = [
- {
- id: 1,
- postingId: 1,
- workspaceId: 1,
- workspaceName: '알터 강남점',
- status: 'SUBMITTED',
- appliedAt: '2026-07-26T07:00:00.000Z',
- schedule: {
- workingDays: ['SATURDAY', 'SUNDAY'],
- startTime: '17:00',
- endTime: '22:00',
- position: '홀서빙',
- },
- description:
- '카페 홀 경험 1년 있습니다. 주말 마감조 성실하게 근무 가능하고, 바리스타 자격증도 보유하고 있어 음료 제조도 도와드릴 수 있습니다. 잘 부탁드립니다!',
- applicant: {
- name: '김지원',
- phoneNumber: '010-2345-6789',
- birthDate: '2001.03.14',
- gender: '여성',
- email: 'jiwon.k@example.com',
- certificates: [
- { name: '바리스타 2급', issuer: '한국커피협회', acquiredAt: '2023.08' },
- {
- name: '위생교육 이수증',
- issuer: '식품안전정보원',
- acquiredAt: '2024.02',
- },
- ],
- },
- },
- {
- id: 2,
- postingId: 2,
- workspaceId: 1,
- workspaceName: '알터 강남점',
- status: 'SHORTLISTED',
- appliedAt: '2026-07-26T04:00:00.000Z',
- schedule: {
- workingDays: ['MONDAY', 'WEDNESDAY', 'FRIDAY'],
- startTime: '09:00',
- endTime: '14:00',
- position: '바리스타',
- },
- description:
- '바리스타로 2년간 근무했습니다. 평일 오전 근무 가능하며 오픈 업무 경험이 많습니다.',
- applicant: {
- name: '이서준',
- phoneNumber: '010-3456-7890',
- birthDate: '1998.06.21',
- gender: '남성',
- email: 'seojun.lee@example.com',
- certificates: [
- { name: '바리스타 1급', issuer: '한국커피협회', acquiredAt: '2022.05' },
- ],
- },
- },
- {
- id: 3,
- postingId: 3,
- workspaceId: 2,
- workspaceName: '알터 성수 로스터리',
- status: 'ACCEPTED',
- appliedAt: '2026-07-25T09:00:00.000Z',
- schedule: {
- workingDays: ['SATURDAY', 'SUNDAY'],
- startTime: '13:00',
- endTime: '18:00',
- position: '베이킹 보조',
- },
- description:
- '베이커리 아르바이트 경험이 있어 빠르게 적응할 수 있습니다. 주말 오후 근무 가능합니다.',
- applicant: {
- name: '박하늘',
- phoneNumber: '010-8765-4321',
- birthDate: '1999.11.02',
- gender: '여성',
- email: 'haneul@example.com',
- certificates: [],
- },
- },
- {
- id: 4,
- postingId: 1,
- workspaceId: 1,
- workspaceName: '알터 강남점',
- status: 'REJECTED',
- appliedAt: '2026-07-24T09:00:00.000Z',
- schedule: {
- workingDays: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY'],
- startTime: '18:00',
- endTime: '22:00',
- position: '마감조',
- },
- description: '평일 저녁 근무 가능합니다. 성실하게 근무하겠습니다.',
- applicant: {
- name: '정민재',
- phoneNumber: '010-1122-3344',
- birthDate: '2000.01.09',
- gender: '남성',
- email: 'minjae.j@example.com',
- certificates: [],
- },
- },
- {
- id: 5,
- postingId: 2,
- workspaceId: 1,
- workspaceName: '알터 강남점',
- status: 'CANCELLED',
- appliedAt: '2026-07-23T09:00:00.000Z',
- schedule: {
- workingDays: ['MONDAY', 'WEDNESDAY'],
- startTime: '07:00',
- endTime: '12:00',
- position: '바리스타',
- },
- description: '오전 근무 가능합니다.',
- applicant: {
- name: '최유나',
- phoneNumber: '010-5566-7788',
- birthDate: '2002.04.18',
- gender: '여성',
- email: 'yuna.choi@example.com',
- certificates: [],
- },
- },
- {
- id: 6,
- postingId: 3,
- workspaceId: 2,
- workspaceName: '알터 성수 로스터리',
- status: 'EXPIRED',
- appliedAt: '2026-07-20T09:00:00.000Z',
- schedule: {
- workingDays: ['SATURDAY'],
- startTime: '13:00',
- endTime: '18:00',
- position: '베이킹 보조',
- },
- description: '주말 단기 근무 희망합니다.',
- applicant: {
- name: '한소희',
- phoneNumber: '010-9900-1122',
- birthDate: '1997.09.30',
- gender: '여성',
- email: 'sohee.han@example.com',
- certificates: [],
- },
- },
-]
diff --git a/src/features/manager/posting/mocks/store.ts b/src/features/manager/posting/mocks/store.ts
deleted file mode 100644
index 4250768a..00000000
--- a/src/features/manager/posting/mocks/store.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * 목업 인메모리 스토어.
- *
- * API 연동 전까지 화면 간 상태(등록/수정/마감/채용 결정)를 유지하기 위한 임시 저장소입니다.
- * 실제 API 연동 시 이 파일과 `mocks/data.ts`는 삭제하고,
- * 각 view-model 훅을 react-query(useQuery/useMutation)로 교체하면 됩니다.
- */
-import { create } from 'zustand'
-
-import {
- MOCK_APPLICATIONS,
- MOCK_POSTINGS,
-} from '@/features/manager/posting/mocks/data'
-import type {
- Application,
- ApplicationStatus,
- Posting,
- PostingFormSchedule,
- PostingFormValues,
- PostingSchedule,
-} from '@/features/manager/posting/types/posting'
-
-/** 네트워크 지연 흉내 — 스켈레톤/로딩 상태를 확인하기 위함 */
-export const MOCK_LATENCY = 400
-
-interface MockPostingState {
- postings: Posting[]
- applications: Application[]
- createPosting: (values: PostingFormValues, workspaceName: string) => number
- updatePosting: (postingId: number, values: PostingFormValues) => void
- closePosting: (postingId: number) => void
- updateApplicationStatus: (
- applicationId: number,
- status: ApplicationStatus
- ) => void
-}
-
-let nextPostingId = MOCK_POSTINGS.length + 1
-
-function toPayAmount(payAmount: string): number {
- const parsed = Number(payAmount.replace(/[^0-9]/g, ''))
- return Number.isNaN(parsed) ? 0 : parsed
-}
-
-/** 폼 전용 필드(key)를 제거해 저장용 일정으로 변환 */
-function toPostingSchedules(
- schedules: PostingFormSchedule[]
-): PostingSchedule[] {
- return schedules.map(schedule => ({
- id: schedule.id,
- workingDays: schedule.workingDays,
- startTime: schedule.startTime,
- endTime: schedule.endTime,
- position: schedule.position,
- // 편집 중 빈 값(0)이 남아 있어도 저장 시 최소 1명 보장
- positionsNeeded: Math.max(1, schedule.positionsNeeded),
- }))
-}
-
-export const useMockPostingStore = create((set, get) => ({
- postings: MOCK_POSTINGS,
- applications: MOCK_APPLICATIONS,
-
- createPosting: (values, workspaceName) => {
- nextPostingId += 1
- const id = nextPostingId
- const posting: Posting = {
- id,
- workspaceId: values.workspaceId ?? 0,
- workspaceName,
- businessType: '카페',
- title: values.title.trim(),
- description: values.description.trim(),
- paymentType: values.paymentType,
- payAmount: toPayAmount(values.payAmount),
- status: 'OPEN',
- applicantCount: 0,
- createdAt: new Date().toISOString(),
- schedules: toPostingSchedules(values.schedules),
- }
- set(state => ({ postings: [posting, ...state.postings] }))
- return id
- },
-
- updatePosting: (postingId, values) => {
- set(state => ({
- postings: state.postings.map(posting =>
- posting.id === postingId
- ? {
- ...posting,
- title: values.title.trim(),
- description: values.description.trim(),
- paymentType: values.paymentType,
- payAmount: toPayAmount(values.payAmount),
- schedules: toPostingSchedules(values.schedules),
- }
- : posting
- ),
- }))
- },
-
- closePosting: postingId => {
- set(state => ({
- postings: state.postings.map(posting =>
- posting.id === postingId ? { ...posting, status: 'CLOSED' } : posting
- ),
- }))
- },
-
- updateApplicationStatus: (applicationId, status) => {
- const { applications } = get()
- set({
- applications: applications.map(application =>
- application.id === applicationId
- ? { ...application, status }
- : application
- ),
- })
- },
-}))
diff --git a/src/features/manager/posting/types/dto.ts b/src/features/manager/posting/types/dto.ts
new file mode 100644
index 00000000..480824b4
--- /dev/null
+++ b/src/features/manager/posting/types/dto.ts
@@ -0,0 +1,255 @@
+import type { PaymentType } from '@/shared/constants/payment'
+import type { WorkingDay } from '@/shared/constants/workingDays'
+import { toTimeOfDay } from '@/shared/lib/toTimeOfDay'
+import type { ApplicationApiStatus } from '@/shared/types/applicationStatus'
+import type {
+ Applicant,
+ Application,
+ ApplicationListItem,
+ Certificate,
+ Posting,
+ PostingListItem,
+ PostingSchedule,
+ PostingStatus,
+} from '@/features/manager/posting/types/posting'
+
+export interface DescribedEnumDto {
+ value: T
+ description: string
+}
+
+export interface PostingScheduleDto {
+ id: number
+ workingDays: WorkingDay[]
+ startTime: string
+ endTime: string
+ positionsNeeded: number
+ positionsAvailable: number
+ position?: string
+}
+
+export interface ManagerPostingListWorkspaceDto {
+ id: number
+ businessName?: string
+ businessType?: string
+ businessTypeDetail?: string
+}
+
+export interface ManagerPostingListItemDto {
+ id: number
+ title: string
+ payAmount: number
+ paymentType: PaymentType
+ createdAt: string
+ schedules: PostingScheduleDto[]
+ workspace: ManagerPostingListWorkspaceDto
+}
+
+export interface ManagerPostingDetailWorkspaceDto {
+ id: number
+ name?: string
+ businessType?: string
+ businessTypeDetail?: string
+ fullAddress?: string
+}
+
+export interface ManagerPostingDetailDto {
+ id: number
+ workspace: ManagerPostingDetailWorkspaceDto
+ title: string
+ description?: string
+ payAmount: number
+ paymentType: PaymentType
+ status: DescribedEnumDto
+ createdAt: string
+ updatedAt: string
+ schedules: PostingScheduleDto[]
+}
+
+export interface PostingApplicationWorkspaceDto {
+ id: number
+ name?: string
+}
+
+export interface PostingApplicationScheduleDto {
+ id: number
+ workingDays: WorkingDay[]
+ startTime: string
+ endTime: string
+ position?: string
+}
+
+export interface PostingApplicationListItemDto {
+ id: number
+ workspace: PostingApplicationWorkspaceDto
+ schedule: PostingApplicationScheduleDto
+ status: DescribedEnumDto
+ applicant: { name?: string }
+ createdAt: string
+}
+
+export interface PostingApplicantCertificateDto {
+ id: number
+ certificateName?: string
+ publisherName?: string
+ issuedAt?: string
+ expiresAt?: string
+}
+
+export interface PostingApplicantDetailDto {
+ id: number
+ name?: string
+ email?: string
+ contact?: string
+ birthday?: string
+ gender?: 'GENDER_MALE' | 'GENDER_FEMALE'
+ userCertificates?: PostingApplicantCertificateDto[]
+}
+
+export interface PostingApplicationDetailDto {
+ id: number
+ workspace: PostingApplicationWorkspaceDto
+ schedule: PostingApplicationScheduleDto
+ description?: string
+ status: DescribedEnumDto
+ applicant: PostingApplicantDetailDto
+ createdAt: string
+}
+
+export interface CreatePostingScheduleRequestDto {
+ workingDays: WorkingDay[]
+ startTime: string
+ endTime: string
+ positionsNeeded: number
+ position?: string
+}
+
+export interface CreatePostingRequestDto {
+ workspaceId: number
+ title: string
+ description: string
+ payAmount: number
+ paymentType: PaymentType
+ schedules: CreatePostingScheduleRequestDto[]
+}
+
+export interface UpdatePostingScheduleRequestDto extends CreatePostingScheduleRequestDto {
+ id: number
+}
+
+export interface UpdatePostingRequestDto {
+ title: string
+ description: string
+ payAmount: number
+ paymentType: PaymentType
+ createSchedules: CreatePostingScheduleRequestDto[]
+ updateSchedules: UpdatePostingScheduleRequestDto[]
+ deleteScheduleIds: number[]
+}
+
+function resolveBusinessType(workspace: {
+ businessType?: string
+ businessTypeDetail?: string
+}): string {
+ return workspace.businessTypeDetail?.trim() || workspace.businessType || ''
+}
+
+function toPostingSchedule(dto: PostingScheduleDto): PostingSchedule {
+ return {
+ id: dto.id ?? null,
+ workingDays: dto.workingDays ?? [],
+ startTime: toTimeOfDay(dto.startTime),
+ endTime: toTimeOfDay(dto.endTime),
+ position: dto.position ?? '',
+ positionsNeeded: dto.positionsNeeded ?? 1,
+ }
+}
+
+export function adaptPostingListItem(
+ dto: ManagerPostingListItemDto
+): PostingListItem {
+ return {
+ id: dto.id,
+ workspaceId: dto.workspace?.id ?? 0,
+ workspaceName: dto.workspace?.businessName ?? '',
+ businessType: resolveBusinessType(dto.workspace ?? {}),
+ title: dto.title,
+ paymentType: dto.paymentType,
+ payAmount: dto.payAmount,
+ schedules: (dto.schedules ?? []).map(toPostingSchedule),
+ createdAt: dto.createdAt,
+ }
+}
+
+export function adaptPostingDetail(dto: ManagerPostingDetailDto): Posting {
+ return {
+ id: dto.id,
+ workspaceId: dto.workspace?.id ?? 0,
+ workspaceName: dto.workspace?.name ?? '',
+ businessType: resolveBusinessType(dto.workspace ?? {}),
+ title: dto.title,
+ description: dto.description ?? '',
+ paymentType: dto.paymentType,
+ payAmount: dto.payAmount,
+ status: dto.status?.value ?? 'OPEN',
+ schedules: (dto.schedules ?? []).map(toPostingSchedule),
+ createdAt: dto.createdAt,
+ }
+}
+
+function toApplicationSchedule(dto: PostingApplicationScheduleDto) {
+ return {
+ workingDays: dto?.workingDays ?? [],
+ startTime: toTimeOfDay(dto?.startTime),
+ endTime: toTimeOfDay(dto?.endTime),
+ position: dto?.position ?? '',
+ }
+}
+
+export function adaptApplicationListItem(
+ dto: PostingApplicationListItemDto
+): ApplicationListItem {
+ return {
+ id: dto.id,
+ workspaceId: dto.workspace?.id ?? 0,
+ workspaceName: dto.workspace?.name ?? '',
+ status: dto.status?.value ?? 'SUBMITTED',
+ appliedAt: dto.createdAt,
+ applicantName: dto.applicant?.name ?? '',
+ schedule: toApplicationSchedule(dto.schedule),
+ }
+}
+
+function toCertificate(dto: PostingApplicantCertificateDto): Certificate {
+ return {
+ name: dto.certificateName ?? '',
+ issuer: dto.publisherName ?? '',
+ acquiredAt: dto.issuedAt ?? '',
+ }
+}
+
+function adaptApplicant(dto: PostingApplicantDetailDto): Applicant {
+ return {
+ name: dto?.name ?? '',
+ phoneNumber: dto?.contact ?? '-',
+ birthDate: dto?.birthday ?? '-',
+ gender: dto?.gender === 'GENDER_FEMALE' ? '여성' : '남성',
+ email: dto?.email ?? '-',
+ certificates: (dto?.userCertificates ?? []).map(toCertificate),
+ }
+}
+
+export function adaptApplicationDetail(
+ dto: PostingApplicationDetailDto
+): Application {
+ return {
+ id: dto.id,
+ workspaceId: dto.workspace?.id ?? 0,
+ workspaceName: dto.workspace?.name ?? '',
+ status: dto.status?.value ?? 'SUBMITTED',
+ appliedAt: dto.createdAt,
+ applicant: adaptApplicant(dto.applicant),
+ schedule: toApplicationSchedule(dto.schedule),
+ description: dto.description ?? '',
+ }
+}
diff --git a/src/features/manager/posting/types/posting.ts b/src/features/manager/posting/types/posting.ts
index 4b5a3d5c..673464e2 100644
--- a/src/features/manager/posting/types/posting.ts
+++ b/src/features/manager/posting/types/posting.ts
@@ -1,31 +1,18 @@
-/**
- * 사장님 구인구직 — 매니저 측 공고/지원자 UI 모델
- *
- * 서버 DTO(`@/features/manager/home/types/posting`)와 지원 상태 enum
- * (`@/shared/types/applicationStatus`)을 계승합니다.
- * API 미연동 단계이므로 화면이 소비하는 UI 모델을 이 파일에서 정의합니다.
- */
import type { ApplicationApiStatus } from '@/shared/types/applicationStatus'
import type { PaymentType } from '@/shared/constants/payment'
import type { WorkingDay } from '@/shared/constants/workingDays'
import { WORKING_DAYS, WORKING_DAY_LABEL } from '@/shared/constants/workingDays'
-// ---- 요일·급여 (shared 재노출 — 슬라이스 내 호출부 경로 유지) ----
export type { PaymentType, WorkingDay }
export { PAYMENT_TYPES, PAYMENT_TYPE_LABEL } from '@/shared/constants/payment'
export { WORKING_DAYS, WORKING_DAY_LABEL }
-// ---- 공고 상태 ----
-/** OPEN=모집중, CLOSED=모집완료 */
-export type PostingStatus = 'OPEN' | 'CLOSED'
+export type PostingStatus = 'OPEN' | 'CLOSED' | 'CANCELLED' | 'DELETED'
-// ---- 지원 상태 ----
-/** 서버 enum을 그대로 사용 (SUBMITTED/SHORTLISTED/ACCEPTED/REJECTED/CANCELLED/EXPIRED/DELETED) */
export type ApplicationStatus = ApplicationApiStatus
-// ---- UI 모델 ----
export interface PostingSchedule {
- /** 기존 일정은 서버 id, 신규 추가분은 null */
+ // 신규 추가분은 null
id: number | null
workingDays: WorkingDay[]
startTime: string
@@ -34,10 +21,16 @@ export interface PostingSchedule {
positionsNeeded: number
}
-export interface Workspace {
+export interface PostingListItem {
id: number
- businessName: string
+ workspaceId: number
+ workspaceName: string
businessType: string
+ title: string
+ paymentType: PaymentType
+ payAmount: number
+ schedules: PostingSchedule[]
+ createdAt: string
}
export interface Posting {
@@ -51,7 +44,6 @@ export interface Posting {
payAmount: number
status: PostingStatus
schedules: PostingSchedule[]
- applicantCount: number
createdAt: string
}
@@ -70,27 +62,33 @@ export interface Applicant {
certificates: Certificate[]
}
+export type ApplicationSchedule = Pick<
+ PostingSchedule,
+ 'workingDays' | 'startTime' | 'endTime' | 'position'
+>
+
+export interface ApplicationListItem {
+ id: number
+ workspaceId: number
+ workspaceName: string
+ status: ApplicationStatus
+ appliedAt: string
+ applicantName: string
+ schedule: ApplicationSchedule
+}
+
export interface Application {
id: number
- postingId: number
workspaceId: number
workspaceName: string
status: ApplicationStatus
- /** 지원 시각 (ISO) — 목록에서 '2시간 전' 형태로 표시 */
appliedAt: string
applicant: Applicant
- /** 지원한 근무일정 */
- schedule: Pick<
- PostingSchedule,
- 'workingDays' | 'startTime' | 'endTime' | 'position'
- >
- /** 지원 메시지 */
+ schedule: ApplicationSchedule
description: string
}
-// ---- 폼 모델 ----
export interface PostingFormSchedule extends PostingSchedule {
- /** 폼 내부에서 일정 카드를 구분하기 위한 로컬 키 */
key: string
}
@@ -103,15 +101,14 @@ export interface PostingFormValues {
description: string
}
-/** 필드별 검증 에러 — 값이 있으면 해당 필드에 에러 표시 */
export interface PostingFormErrors {
workspaceId?: string
title?: string
schedules?: string
payAmount?: string
+ description?: string
}
-// ---- 포맷터 ----
export function formatWorkingDays(days: WorkingDay[]): string {
if (days.length === 0) return '-'
return WORKING_DAYS.filter(day => days.includes(day))
diff --git a/src/features/manager/posting/ui/ApplicantCard.tsx b/src/features/manager/posting/ui/ApplicantCard.tsx
index 3aa8eece..6a1c6dc1 100644
--- a/src/features/manager/posting/ui/ApplicantCard.tsx
+++ b/src/features/manager/posting/ui/ApplicantCard.tsx
@@ -5,17 +5,16 @@ import {
formatRelativeTime,
formatTimeRange,
formatWorkingDays,
- type Application,
+ type ApplicationListItem,
} from '@/features/manager/posting/types/posting'
interface ApplicantCardProps {
- application: Application
+ application: ApplicationListItem
onClick: () => void
}
-/** 지원자 카드 — 업장·상태·이름·지원시각·희망 근무 (PDF ApplicantCard) */
export function ApplicantCard({ application, onClick }: ApplicantCardProps) {
- const { applicant, schedule } = application
+ const { applicantName, schedule } = application
return (
)
diff --git a/src/features/manager/posting/ui/FilterBar.tsx b/src/features/manager/posting/ui/FilterBar.tsx
index 567f1b31..434d78a1 100644
--- a/src/features/manager/posting/ui/FilterBar.tsx
+++ b/src/features/manager/posting/ui/FilterBar.tsx
@@ -1,4 +1,4 @@
-import type { WorkspaceFilter } from '@/features/manager/posting/hooks/usePostingListViewModel'
+import type { WorkspaceFilter } from '@/features/manager/posting/hooks/query/useWorkspaceFilterOptions'
import { SelectDropdown } from '@/shared/ui/common/SelectDropdown'
import type { SelectOption } from '@/shared/ui/common/SelectDropdown'
@@ -12,7 +12,6 @@ interface FilterBarProps
{
totalCount: number
}
-/** 업장·상태 드롭다운 + 총 N건 */
export function FilterBar({
workspaceOptions,
workspaceValue,
diff --git a/src/features/manager/posting/ui/PostingFormFields.tsx b/src/features/manager/posting/ui/PostingFormFields.tsx
index d89fc1b2..15730af1 100644
--- a/src/features/manager/posting/ui/PostingFormFields.tsx
+++ b/src/features/manager/posting/ui/PostingFormFields.tsx
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import type { PostingFormViewModel } from '@/features/manager/posting/hooks/usePostingForm'
-import { MOCK_WORKSPACES } from '@/features/manager/posting/mocks/data'
+import { useWorkspaceSelectOptions } from '@/features/manager/posting/hooks/query/useWorkspaceFilterOptions'
import AlertIcon from '@/assets/icons/posting/Alert.svg?react'
import { ScheduleEditor } from '@/features/manager/posting/ui/ScheduleEditor'
import {
@@ -15,7 +15,6 @@ interface PostingFormFieldsProps {
form: PostingFormViewModel
}
-/** 섹션 카드 — 라벨 + 필수 표시 + 에러 테두리/메시지 */
function Section({
label,
required = false,
@@ -57,14 +56,9 @@ function Section({
const inputClassName =
'h-12 w-full rounded-xl border bg-white px-3.5 typography-body02-regular text-text-100 placeholder:text-text-50 focus:outline-none'
-/** 공고 등록·수정 공용 폼 — 업장/제목/키워드/근무일정/급여/상세내용 */
export function PostingFormFields({ form }: PostingFormFieldsProps) {
const { values, errors } = form
-
- const workspaceOptions = MOCK_WORKSPACES.map(workspace => ({
- value: workspace.id,
- label: workspace.businessName,
- }))
+ const { workspaceOptions } = useWorkspaceSelectOptions()
return (
@@ -144,13 +138,18 @@ export function PostingFormFields({ form }: PostingFormFieldsProps) {
-
diff --git a/src/features/manager/posting/ui/PostingListCard.tsx b/src/features/manager/posting/ui/PostingListCard.tsx
index 6925eacb..bd77462b 100644
--- a/src/features/manager/posting/ui/PostingListCard.tsx
+++ b/src/features/manager/posting/ui/PostingListCard.tsx
@@ -1,45 +1,30 @@
import CalendarIcon from '@/assets/icons/posting/Calendar.svg?react'
import ClockIcon from '@/assets/icons/posting/Clock.svg?react'
-import { ManagerPostingStatusBadge } from '@/features/manager/posting/ui/ManagerPostingStatusBadge'
import {
formatTimeRange,
formatWorkingDays,
PAYMENT_TYPE_LABEL,
- type Posting,
+ type PostingListItem,
} from '@/features/manager/posting/types/posting'
-import { cn } from '@/shared/lib/utils'
interface PostingListCardProps {
- posting: Posting
+ posting: PostingListItem
onClick: () => void
}
-/** 내 공고 카드 — PDF OngoingPostingCard */
export function PostingListCard({ posting, onClick }: PostingListCardProps) {
- const isClosed = posting.status === 'CLOSED'
const firstSchedule = posting.schedules[0]
return (