From b2f89b219a169ce5cf08f5c349a6d51ab71bd3a1 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Sun, 12 Apr 2026 18:39:16 +0900 Subject: [PATCH 01/39] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=84=88=EB=B9=84=20=EB=B0=8F=20Navbar=20=EB=B0=B0=EA=B2=BD?= =?UTF-8?q?=EC=83=89=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/user/ui/AppliedStoreList.tsx | 2 +- src/features/home/user/ui/HomeScheduleCalendar.tsx | 2 +- src/features/home/user/ui/WorkingStoresList.tsx | 2 +- src/shared/ui/common/Navbar.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/features/home/user/ui/AppliedStoreList.tsx b/src/features/home/user/ui/AppliedStoreList.tsx index 41fd20d..cb8d8ea 100644 --- a/src/features/home/user/ui/AppliedStoreList.tsx +++ b/src/features/home/user/ui/AppliedStoreList.tsx @@ -24,7 +24,7 @@ export function AppliedStoreList({ const rightLabel = recentLabel ?? `최근 지원한 ${visibleStores.length}개` return ( -
+

{title}

diff --git a/src/features/home/user/ui/HomeScheduleCalendar.tsx b/src/features/home/user/ui/HomeScheduleCalendar.tsx index a2af202..491aa40 100644 --- a/src/features/home/user/ui/HomeScheduleCalendar.tsx +++ b/src/features/home/user/ui/HomeScheduleCalendar.tsx @@ -23,7 +23,7 @@ export function HomeScheduleCalendar({ isLoading = false, }: HomeScheduleCalendarProps) { return ( -

+
{mode === 'monthly' && ( +

{title}

diff --git a/src/shared/ui/common/Navbar.tsx b/src/shared/ui/common/Navbar.tsx index 28f49fc..202c6a5 100644 --- a/src/shared/ui/common/Navbar.tsx +++ b/src/shared/ui/common/Navbar.tsx @@ -30,7 +30,7 @@ export function Navbar({ } return ( -
+
{isMain ? (
From b8112824f1aab32424ae8e409dbf7d49f600ce9f Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Sun, 12 Apr 2026 18:40:33 +0900 Subject: [PATCH 02/39] =?UTF-8?q?feat:=20=EC=95=8C=EB=B0=94=EC=83=9D=20?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/user/home/index.tsx | 126 ++++++++++++---------------------- 1 file changed, 42 insertions(+), 84 deletions(-) diff --git a/src/pages/user/home/index.tsx b/src/pages/user/home/index.tsx index f6b1cfb..620b88c 100644 --- a/src/pages/user/home/index.tsx +++ b/src/pages/user/home/index.tsx @@ -1,94 +1,52 @@ -import { useEffect, useState } from 'react' import { - getDailySchedules, - getMonthlySchedules, - getWeeklySchedules, HomeScheduleCalendar, - type CalendarViewData, - type HomeCalendarMode, + WorkingStoresList, + AppliedStoreList, } from '@/features/home' -import { getRangeParamsByMode } from '@/features/home/user/lib/date' +import type { WorkingStoreItem } from '@/features/home/user/ui/WorkingStoreCard' +import type { AppliedStoreItem } from '@/features/home/user/ui/AppliedStoreList' +import { Navbar } from '@/shared/ui/common/Navbar' + +const WORKING_STORES: WorkingStoreItem[] = [ + { + workspaceId: 1, + businessName: '스타벅스 강남점', + employedAt: '2024-01-01', + nextShiftDateTime: '2025-04-15T09:00:00', + }, + { + workspaceId: 2, + businessName: '맥도날드 홍대점', + employedAt: '2024-03-01', + nextShiftDateTime: '2025-04-18T14:00:00', + }, +] + +const APPLIED_STORES: AppliedStoreItem[] = [ + { id: 1, storeName: '이디야커피 신촌점', status: 'applied' }, + { id: 2, storeName: '베스킨라빈스 마포점', status: 'rejected' }, + { id: 3, storeName: '파리바게뜨 합정점', status: 'applied' }, +] export function UserHomePage() { - const [mode, setMode] = useState('monthly') - const [baseDate, setBaseDate] = useState(new Date()) - const [data, setData] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [errorMessage, setErrorMessage] = useState('') - - useEffect(() => { - let mounted = true - - const fetchData = async () => { - setIsLoading(true) - setErrorMessage('') - try { - const range = getRangeParamsByMode(baseDate, mode) - const result = - mode === 'monthly' - ? await getMonthlySchedules(range) - : mode === 'weekly' - ? await getWeeklySchedules(range) - : await getDailySchedules(range) - - if (!mounted) return - setData(result) - } catch (error) { - if (!mounted) return - setData(null) - setErrorMessage( - error instanceof Error - ? error.message - : '홈 스케줄을 불러오는 중 오류가 발생했습니다.' - ) - } finally { - if (mounted) { - setIsLoading(false) - } - } - } - - fetchData() - - return () => { - mounted = false - } - }, [baseDate, mode]) - return ( -
-
- {(['monthly', 'weekly', 'daily'] as const).map(item => ( - - ))} +
+
+ +
+
+ {}} + /> + + {}} /> + + {}} />
- - {errorMessage && ( -
- {errorMessage} -
- )} - -
) } From 0c56e8137dc0ab34aee6e239dfe55d17df836634 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Sun, 12 Apr 2026 18:55:38 +0900 Subject: [PATCH 03/39] =?UTF-8?q?fix:=20Navbar=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=ED=8F=AC=EA=B7=B8=EB=9E=98=ED=94=BC=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/common/Navbar.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/shared/ui/common/Navbar.tsx b/src/shared/ui/common/Navbar.tsx index 202c6a5..d080d67 100644 --- a/src/shared/ui/common/Navbar.tsx +++ b/src/shared/ui/common/Navbar.tsx @@ -2,7 +2,6 @@ import AlterLogo from '@/assets/Alter-logo.png' import BellIcon from '@/assets/icons/nav/bell.svg' import MenuIcon from '@/assets/icons/nav/menu.svg' import ChevronLeftIcon from '@/assets/icons/nav/chevron-left.svg' -import { typography } from '@/shared/lib/tokens' import { useNavigate } from 'react-router-dom' type NavbarVariant = 'main' | 'detail' @@ -52,14 +51,7 @@ export function Navbar({
{!isMain && ( {title} From ffebc9e0e2a01629470dfa86dee6ee348836ac18 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Sun, 12 Apr 2026 19:13:03 +0900 Subject: [PATCH 04/39] =?UTF-8?q?feat:=20=EA=B7=BC=EB=AC=B4=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=EA=B0=80=EA=B2=8C=20=EC=83=81=EC=84=B8=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.tsx | 4 ++ src/assets/icons/home/crown-solid.svg | 5 ++ src/assets/icons/home/users.svg | 13 ++++ src/pages/manager/home/index.tsx | 31 +++----- src/pages/user/home/index.tsx | 5 +- src/pages/user/workspace-detail/index.tsx | 87 +++++++++++++++++++++++ src/pages/user/workspace/index.tsx | 61 ++++++++++++++++ src/shared/ui/home/WorkerRoleBadge.tsx | 6 +- 8 files changed, 189 insertions(+), 23 deletions(-) create mode 100644 src/assets/icons/home/crown-solid.svg create mode 100644 src/assets/icons/home/users.svg create mode 100644 src/pages/user/workspace-detail/index.tsx create mode 100644 src/pages/user/workspace/index.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index df2e7c3..040bd5c 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -14,6 +14,8 @@ import { JobLookupMapPage } from '@/pages/user/job-lookup-map' import { SchedulePage } from '@/pages/user/schedule' import { UserHomePage } from '@/pages/user/home' import { WorkspaceMembersPage } from '@/pages/user/workspace-members' +import { WorkspacePage } from '@/pages/user/workspace' +import { WorkspaceDetailPage } from '@/pages/user/workspace-detail' import { MobileLayout } from '@/shared/ui/MobileLayout' import { MobileLayoutWithDocbar } from '@/shared/ui/MobileLayoutWithDocbar' @@ -63,6 +65,8 @@ export function App() { path="/workspaces/:workspaceId/members" element={} /> + } /> + } /> }> diff --git a/src/assets/icons/home/crown-solid.svg b/src/assets/icons/home/crown-solid.svg new file mode 100644 index 0000000..b731c8a --- /dev/null +++ b/src/assets/icons/home/crown-solid.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/home/users.svg b/src/assets/icons/home/users.svg new file mode 100644 index 0000000..476d9e7 --- /dev/null +++ b/src/assets/icons/home/users.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/pages/manager/home/index.tsx b/src/pages/manager/home/index.tsx index 0de94f5..6521df2 100644 --- a/src/pages/manager/home/index.tsx +++ b/src/pages/manager/home/index.tsx @@ -1,9 +1,8 @@ import { Navbar } from '@/shared/ui/common/Navbar' -import { WorkerImageCard } from '@/shared/ui/manager/WorkerImageCard' -import { - StoreWorkerListItem, - type StoreWorkerRole, -} from '@/features/home/manager/ui/StoreWorkerListItem' +import { TodayWorkerList } from '@/features/home/manager/ui/TodayWorkerList' +import type { TodayWorkerItem } from '@/features/home/manager/ui/TodayWorkerList' +import { StoreWorkerListItem } from '@/features/home/manager/ui/StoreWorkerListItem' +import type { StoreWorkerRole } from '@/features/home/manager/ui/StoreWorkerListItem' import { OngoingPostingCard, type JobPostingItem, @@ -16,10 +15,10 @@ import { import homeBanner from '@/assets/home.png' // 더미 데이터 -const TODAY_WORKERS = [ - { name: '알바생1', timeRange: '00:00 ~ 00:00' }, - { name: '알바생2', timeRange: '00:00 ~ 00:00' }, -] as const +const TODAY_WORKERS: TodayWorkerItem[] = [ + { id: '1', name: '알바생1', workTime: '00:00 ~ 00:00' }, + { id: '2', name: '알바생2', workTime: '00:00 ~ 00:00' }, +] interface StoreWorkerData { id: string @@ -103,18 +102,8 @@ export function ManagerHomePage() {
MM월 dd일
전체 보기
-
- 오늘 근무자는 6명이에요 -
- -
- {TODAY_WORKERS.map(worker => ( - - ))} +
+
diff --git a/src/pages/user/home/index.tsx b/src/pages/user/home/index.tsx index 620b88c..4efd548 100644 --- a/src/pages/user/home/index.tsx +++ b/src/pages/user/home/index.tsx @@ -6,6 +6,7 @@ import { import type { WorkingStoreItem } from '@/features/home/user/ui/WorkingStoreCard' import type { AppliedStoreItem } from '@/features/home/user/ui/AppliedStoreList' import { Navbar } from '@/shared/ui/common/Navbar' +import { useNavigate } from 'react-router-dom' const WORKING_STORES: WorkingStoreItem[] = [ { @@ -29,6 +30,8 @@ const APPLIED_STORES: AppliedStoreItem[] = [ ] export function UserHomePage() { + const navigate = useNavigate() + return (
@@ -43,7 +46,7 @@ export function UserHomePage() { onDateChange={() => {}} /> - {}} /> + navigate('/workspace')} /> {}} />
diff --git a/src/pages/user/workspace-detail/index.tsx b/src/pages/user/workspace-detail/index.tsx new file mode 100644 index 0000000..f8b2788 --- /dev/null +++ b/src/pages/user/workspace-detail/index.tsx @@ -0,0 +1,87 @@ +import { Navbar } from '@/shared/ui/common/Navbar' +import { HomeScheduleCalendar } from '@/features/home' +import { StoreWorkerListItem } from '@/features/home/manager/ui/StoreWorkerListItem' +import type { StoreWorkerRole } from '@/features/home/manager/ui/StoreWorkerListItem' +import CrownIcon from '@/assets/icons/home/crown-solid.svg' +import UsersIcon from '@/assets/icons/home/users.svg' + +interface WorkspaceMember { + id: string + name: string + role: StoreWorkerRole + nextWorkDate: string + profileImageUrl?: string +} + +const DUMMY_STORE_NAME = '집게리아' + +const DUMMY_MANAGERS: WorkspaceMember[] = [ + { id: '1', name: '이름임', role: 'owner', nextWorkDate: '2025. 1. 1.' }, +] + +const DUMMY_WORKERS: WorkspaceMember[] = [ + { id: '2', name: '이름임', role: 'manager', nextWorkDate: '2025. 1. 1.' }, + { id: '3', name: '이름임', role: 'staff', nextWorkDate: '2025. 1. 1.' }, +] + +export function WorkspaceDetailPage() { + return ( +
+ +
+ {}} + /> + +
+
+ + + 관리자 ({DUMMY_MANAGERS.length}명) + +
+
+ {DUMMY_MANAGERS.map(member => ( +
+ {}} + /> +
+ ))} +
+
+ +
+
+ + + 근무자 ({DUMMY_WORKERS.length}명) + +
+
+ {DUMMY_WORKERS.map(member => ( +
+ {}} + /> +
+ ))} +
+
+
+
+ ) +} diff --git a/src/pages/user/workspace/index.tsx b/src/pages/user/workspace/index.tsx new file mode 100644 index 0000000..238a55d --- /dev/null +++ b/src/pages/user/workspace/index.tsx @@ -0,0 +1,61 @@ +import { useNavigate } from 'react-router-dom' +import { Navbar } from '@/shared/ui/common/Navbar' +import { + WorkingStoreCard, + type WorkingStoreItem, +} from '@/features/home/user/ui/WorkingStoreCard' + +const DUMMY_STORES: WorkingStoreItem[] = [ + { + workspaceId: 1, + businessName: '출근하기 싫은 가게 부천점', + employedAt: '2024-01-01', + nextShiftDateTime: '2026-01-09T09:00:00', + }, + { + workspaceId: 2, + businessName: '일하기 싫은 가게 고척점', + employedAt: '2024-03-01', + nextShiftDateTime: '2026-01-11T14:00:00', + }, + { + workspaceId: 3, + businessName: '초 메가커피 신용산 레미안', + employedAt: '2024-06-01', + nextShiftDateTime: '2026-02-09T10:00:00', + }, + { + workspaceId: 4, + businessName: '동양미래대학교 서비스', + employedAt: '2024-09-01', + nextShiftDateTime: '2026-02-09T10:00:00', + }, + { + workspaceId: 5, + businessName: '집게리아', + employedAt: '2025-01-01', + nextShiftDateTime: '2026-02-09T10:00:00', + }, +] + +export function WorkspacePage() { + const navigate = useNavigate() + + return ( +
+ +
+ {DUMMY_STORES.map(store => ( + + ))} +
+
+ ) +} diff --git a/src/shared/ui/home/WorkerRoleBadge.tsx b/src/shared/ui/home/WorkerRoleBadge.tsx index 351bbbd..4976344 100644 --- a/src/shared/ui/home/WorkerRoleBadge.tsx +++ b/src/shared/ui/home/WorkerRoleBadge.tsx @@ -1,4 +1,4 @@ -type WorkerRole = 'staff' | 'manager' +type WorkerRole = 'staff' | 'manager' | 'owner' interface WorkerRoleBadgeProps { role: WorkerRole @@ -14,6 +14,10 @@ const ROLE_STYLE_MAP = { label: '매니저', containerClassName: 'bg-main-700', }, + owner: { + label: '사장님', + containerClassName: 'bg-main-700', + }, } as const export function WorkerRoleBadge({ From 55a6931ff7eb2a2529dc0a3554b6156eecbea64e Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Sun, 12 Apr 2026 19:51:55 +0900 Subject: [PATCH 05/39] =?UTF-8?q?feat:=20=EB=82=B4=EA=B0=80=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=ED=95=9C=20=EA=B0=80=EA=B2=8C=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.tsx | 7 +- .../user/hooks/useAppliedStoresViewModel.ts | 81 +++++++++++++ src/features/home/user/types/appliedStore.ts | 16 +++ .../home/user/ui/AppliedStoreListItem.tsx | 33 ++++++ src/pages/user/applied-stores/index.tsx | 108 ++++++++++++++++++ src/pages/user/home/index.tsx | 10 +- src/shared/ui/common/Navbar.tsx | 4 +- 7 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 src/features/home/user/hooks/useAppliedStoresViewModel.ts create mode 100644 src/features/home/user/types/appliedStore.ts create mode 100644 src/features/home/user/ui/AppliedStoreListItem.tsx create mode 100644 src/pages/user/applied-stores/index.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index 040bd5c..466a91c 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -16,6 +16,7 @@ import { UserHomePage } from '@/pages/user/home' import { WorkspaceMembersPage } from '@/pages/user/workspace-members' import { WorkspacePage } from '@/pages/user/workspace' import { WorkspaceDetailPage } from '@/pages/user/workspace-detail' +import { AppliedStoresPage } from '@/pages/user/applied-stores' import { MobileLayout } from '@/shared/ui/MobileLayout' import { MobileLayoutWithDocbar } from '@/shared/ui/MobileLayoutWithDocbar' @@ -66,7 +67,11 @@ export function App() { element={} /> } /> - } /> + } + /> + } /> }> diff --git a/src/features/home/user/hooks/useAppliedStoresViewModel.ts b/src/features/home/user/hooks/useAppliedStoresViewModel.ts new file mode 100644 index 0000000..e72c1dc --- /dev/null +++ b/src/features/home/user/hooks/useAppliedStoresViewModel.ts @@ -0,0 +1,81 @@ +import { useEffect, useRef, useState } from 'react' +import type { + ApplicationStatus, + FilterType, + AppliedStoreData, +} from '@/features/home/user/types/appliedStore' + +const FILTER_OPTIONS: { key: FilterType; label: string }[] = [ + { key: 'completed', label: '지원 완료' }, + { key: 'viewed', label: '열람' }, + { key: 'not_viewed', label: '미열람' }, + { key: 'cancelled', label: '지원 취소' }, +] + +const STATUS_SECTIONS: { key: ApplicationStatus; label: string }[] = [ + { key: 'submitted', label: '제출됨' }, + { key: 'accepted', label: '수락됨' }, + { key: 'cancelled', label: '취소됨' }, +] + +function getCardStatus(status: ApplicationStatus): 'applied' | 'rejected' { + return status === 'cancelled' ? 'rejected' : 'applied' +} + +function getFilterLabel(filter: FilterType): string { + if (filter === 'all') return '전체' + return FILTER_OPTIONS.find(o => o.key === filter)?.label ?? '전체' +} + +export function useAppliedStoresViewModel(stores: AppliedStoreData[]) { + const [selectedFilter, setSelectedFilter] = useState('all') + const [isDropdownOpen, setIsDropdownOpen] = useState(false) + const dropdownRef = useRef(null) + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + setIsDropdownOpen(false) + } + } + if (isDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [isDropdownOpen]) + + const filteredStores = + selectedFilter === 'all' + ? stores + : stores.filter(s => s.filterType === selectedFilter) + + const grouped = STATUS_SECTIONS.map(section => ({ + ...section, + stores: filteredStores.filter(s => s.status === section.key), + })).filter(section => section.stores.length > 0) + + const filterLabel = getFilterLabel(selectedFilter) + + function toggleDropdown() { + setIsDropdownOpen(prev => !prev) + } + + function selectFilter(filter: FilterType) { + setSelectedFilter(filter) + setIsDropdownOpen(false) + } + + return { + filterLabel, + isDropdownOpen, + dropdownRef, + filterOptions: FILTER_OPTIONS, + grouped, + toggleDropdown, + selectFilter, + getCardStatus, + } +} diff --git a/src/features/home/user/types/appliedStore.ts b/src/features/home/user/types/appliedStore.ts new file mode 100644 index 0000000..f056454 --- /dev/null +++ b/src/features/home/user/types/appliedStore.ts @@ -0,0 +1,16 @@ +export type ApplicationStatus = 'submitted' | 'accepted' | 'cancelled' + +export type FilterType = + | 'all' + | 'completed' + | 'viewed' + | 'not_viewed' + | 'cancelled' + +export interface AppliedStoreData { + id: number + storeName: string + status: ApplicationStatus + filterType: FilterType + thumbnailUrl?: string +} diff --git a/src/features/home/user/ui/AppliedStoreListItem.tsx b/src/features/home/user/ui/AppliedStoreListItem.tsx new file mode 100644 index 0000000..b4d3c23 --- /dev/null +++ b/src/features/home/user/ui/AppliedStoreListItem.tsx @@ -0,0 +1,33 @@ +import { ApplicationStatusBadge } from '@/shared/ui/home/ApplicationStatusBadge' + +interface AppliedStoreListItemProps { + storeName: string + status: 'applied' | 'rejected' + thumbnailUrl?: string +} + +export function AppliedStoreListItem({ + storeName, + status, + thumbnailUrl, +}: AppliedStoreListItemProps) { + return ( +
+
+ {thumbnailUrl ? ( + {storeName} + ) : null} +
+

+ {storeName} +

+ +
+ ) +} + +export type { AppliedStoreListItemProps } diff --git a/src/pages/user/applied-stores/index.tsx b/src/pages/user/applied-stores/index.tsx new file mode 100644 index 0000000..8f6b916 --- /dev/null +++ b/src/pages/user/applied-stores/index.tsx @@ -0,0 +1,108 @@ +import { Navbar } from '@/shared/ui/common/Navbar' +import { AppliedStoreListItem } from '@/features/home/user/ui/AppliedStoreListItem' +import { useAppliedStoresViewModel } from '@/features/home/user/hooks/useAppliedStoresViewModel' +import type { AppliedStoreData } from '@/features/home/user/types/appliedStore' +import DownIcon from '@/assets/icons/home/chevron-down.svg?react' + +const DUMMY_STORES: AppliedStoreData[] = [ + { + id: 1, + storeName: '출근하기 싫은 가게 부천점', + status: 'submitted', + filterType: 'completed', + }, + { + id: 2, + storeName: '집에 가고 싶은 가게 부천점', + status: 'submitted', + filterType: 'completed', + }, + { + id: 3, + storeName: '출근하기 싫은 가게 고척점', + status: 'submitted', + filterType: 'not_viewed', + }, + { + id: 4, + storeName: '출근하기 싫은 가게 고척점', + status: 'accepted', + filterType: 'viewed', + }, + { + id: 5, + storeName: '집에 가고 싶은 가게 부천점', + status: 'cancelled', + filterType: 'cancelled', + }, +] + +export function AppliedStoresPage() { + const { + filterLabel, + isDropdownOpen, + dropdownRef, + filterOptions, + grouped, + toggleDropdown, + selectFilter, + getCardStatus, + } = useAppliedStoresViewModel(DUMMY_STORES) + + return ( +
+ +
+
+ + + {isDropdownOpen && ( +
+ {filterOptions.map((option, index) => ( + + ))} +
+ )} +
+ +
+ {grouped.map(section => ( +
+

+ {section.label} +

+
+ {section.stores.map(store => ( + + ))} +
+
+ ))} +
+
+
+ ) +} diff --git a/src/pages/user/home/index.tsx b/src/pages/user/home/index.tsx index 4efd548..a3d43a3 100644 --- a/src/pages/user/home/index.tsx +++ b/src/pages/user/home/index.tsx @@ -46,9 +46,15 @@ export function UserHomePage() { onDateChange={() => {}} /> - navigate('/workspace')} /> + navigate('/workspace')} + /> - {}} /> + navigate('/applied-stores')} + />
) diff --git a/src/shared/ui/common/Navbar.tsx b/src/shared/ui/common/Navbar.tsx index d080d67..2df8625 100644 --- a/src/shared/ui/common/Navbar.tsx +++ b/src/shared/ui/common/Navbar.tsx @@ -50,9 +50,7 @@ export function Navbar({
{!isMain && ( - + {title} )} From 5453eaa80deb6071eb7ac0c99ba320c7f1510f2a Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Sun, 12 Apr 2026 20:06:22 +0900 Subject: [PATCH 06/39] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B3=A0=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=A1=B0=ED=9A=8C=20=EB=AA=A8=EB=8B=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/index.ts | 1 + src/features/home/user/types/appliedStore.ts | 20 +++ .../home/user/ui/AppliedStoreDetailModal.tsx | 134 ++++++++++++++++++ .../home/user/ui/AppliedStoreListItem.tsx | 21 ++- src/pages/user/applied-stores/index.tsx | 70 +++++++-- tailwind.config.js | 1 + 6 files changed, 237 insertions(+), 10 deletions(-) create mode 100644 src/features/home/user/ui/AppliedStoreDetailModal.tsx diff --git a/src/features/home/index.ts b/src/features/home/index.ts index ebdc3b0..a50c76a 100644 --- a/src/features/home/index.ts +++ b/src/features/home/index.ts @@ -5,6 +5,7 @@ export { WorkspaceChangeCard } from '@/features/home/manager/ui/WorkspaceChangeC export { WorkspaceChangeList } from '@/features/home/manager/ui/WorkspaceChangeList' export { AppliedStoreCard } from '@/features/home/user/ui/AppliedStoreCard' export { AppliedStoreList } from '@/features/home/user/ui/AppliedStoreList' +export { AppliedStoreDetailModal } from '@/features/home/user/ui/AppliedStoreDetailModal' export { WorkingStoresList } from '@/features/home/user/ui/WorkingStoresList' export { WorkingStoreCard } from '@/features/home/user/ui/WorkingStoreCard' export type { diff --git a/src/features/home/user/types/appliedStore.ts b/src/features/home/user/types/appliedStore.ts index f056454..6f97964 100644 --- a/src/features/home/user/types/appliedStore.ts +++ b/src/features/home/user/types/appliedStore.ts @@ -7,10 +7,30 @@ export type FilterType = | 'not_viewed' | 'cancelled' +export const WEEKDAY_LABELS = [ + '월', + '화', + '수', + '목', + '금', + '토', + '일', +] as const + +export type WeekdayLabel = (typeof WEEKDAY_LABELS)[number] + +/** 지원서에 기재된 내용 (조회 전용) */ +export interface AppliedApplicationDetail { + selectedWeekdays: WeekdayLabel[] + timeRangeLabel: string + selfIntroduction: string +} + export interface AppliedStoreData { id: number storeName: string status: ApplicationStatus filterType: FilterType thumbnailUrl?: string + applicationDetail?: AppliedApplicationDetail } diff --git a/src/features/home/user/ui/AppliedStoreDetailModal.tsx b/src/features/home/user/ui/AppliedStoreDetailModal.tsx new file mode 100644 index 0000000..5f511b8 --- /dev/null +++ b/src/features/home/user/ui/AppliedStoreDetailModal.tsx @@ -0,0 +1,134 @@ +import { useEffect } from 'react' +import { + WEEKDAY_LABELS, + type AppliedApplicationDetail, +} from '@/features/home/user/types/appliedStore' + +interface AppliedStoreDetailModalProps { + isOpen: boolean + onClose: () => void + storeName: string + detail: AppliedApplicationDetail + showCancelButton: boolean + onCancel?: () => void +} + +export function AppliedStoreDetailModal({ + isOpen, + onClose, + storeName, + detail, + showCancelButton, + onCancel, +}: AppliedStoreDetailModalProps) { + useEffect(() => { + if (!isOpen) return + const prev = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = prev + } + }, [isOpen]) + + useEffect(() => { + if (!isOpen) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [isOpen, onClose]) + + if (!isOpen) return null + + const selectedSet = new Set(detail.selectedWeekdays) + + return ( +
+ +
+ )} + + {!showCancelButton &&
} +
+
+ ) +} + +export type { AppliedStoreDetailModalProps } diff --git a/src/features/home/user/ui/AppliedStoreListItem.tsx b/src/features/home/user/ui/AppliedStoreListItem.tsx index b4d3c23..133ffe6 100644 --- a/src/features/home/user/ui/AppliedStoreListItem.tsx +++ b/src/features/home/user/ui/AppliedStoreListItem.tsx @@ -4,15 +4,34 @@ interface AppliedStoreListItemProps { storeName: string status: 'applied' | 'rejected' thumbnailUrl?: string + onClick?: () => void } export function AppliedStoreListItem({ storeName, status, thumbnailUrl, + onClick, }: AppliedStoreListItemProps) { return ( -
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick() + } + } + : undefined + } + >
{thumbnailUrl ? ( (INITIAL_STORES) + const [selectedStore, setSelectedStore] = useState( + null + ) + const { filterLabel, isDropdownOpen, @@ -47,13 +79,21 @@ export function AppliedStoresPage() { toggleDropdown, selectFilter, getCardStatus, - } = useAppliedStoresViewModel(DUMMY_STORES) + } = useAppliedStoresViewModel(stores) + + const closeDetail = () => setSelectedStore(null) + + const handleCancelApplication = () => { + if (!selectedStore) return + setStores(prev => prev.filter(s => s.id !== selectedStore.id)) + closeDetail() + } return ( -
+
-
-
+
+
+ + {selectedStore?.applicationDetail && ( + + )}
) } diff --git a/tailwind.config.js b/tailwind.config.js index 4853b50..bf7381c 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -34,6 +34,7 @@ export default { // Main 색상 main: { DEFAULT: '#2ce283', + 900: '#42e590', 700: '#6ceba9', 500: '#96f1c1', 300: '#c0f7da', From 17f58f26ce41567bc9209a5a4c6a4e0711ceb007 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Mon, 13 Apr 2026 01:38:16 +0900 Subject: [PATCH 07/39] =?UTF-8?q?feat:=20=EB=82=98=EC=9D=98=20=EA=B7=BC?= =?UTF-8?q?=EB=AC=B4=20=EC=8A=A4=EC=BC=80=EC=A4=84=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?api=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/index.ts | 5 +- src/features/home/user/api/schedule.ts | 45 ++++---- .../user/hooks/useHomeScheduleViewModel.ts | 40 +++++++ .../user/hooks/useScheduleListViewModel.ts | 56 ++++++++++ src/features/home/user/lib/date.test.ts | 30 ++--- src/features/home/user/lib/date.ts | 63 +++++++---- src/features/home/user/types/scheduleList.ts | 8 ++ src/pages/user/home/index.tsx | 13 ++- .../user/schedule/components/ScheduleItem.tsx | 4 +- src/pages/user/schedule/hooks/useSchedule.ts | 33 ------ src/pages/user/schedule/index.tsx | 16 +-- src/shared/api/schedule.ts | 59 ---------- src/shared/hooks/index.ts | 14 +-- src/shared/hooks/useSelfScheduleQuery.ts | 103 ------------------ src/shared/hooks/useWorkspaceManagersQuery.ts | 5 +- src/shared/hooks/useWorkspaceWorkersQuery.ts | 5 +- src/shared/lib/queryKeys.ts | 21 ++++ src/shared/stores/useScheduleStore.ts | 78 ------------- 18 files changed, 229 insertions(+), 369 deletions(-) create mode 100644 src/features/home/user/hooks/useHomeScheduleViewModel.ts create mode 100644 src/features/home/user/hooks/useScheduleListViewModel.ts create mode 100644 src/features/home/user/types/scheduleList.ts delete mode 100644 src/pages/user/schedule/hooks/useSchedule.ts delete mode 100644 src/shared/api/schedule.ts delete mode 100644 src/shared/hooks/useSelfScheduleQuery.ts create mode 100644 src/shared/lib/queryKeys.ts delete mode 100644 src/shared/stores/useScheduleStore.ts diff --git a/src/features/home/index.ts b/src/features/home/index.ts index a50c76a..3d3fc80 100644 --- a/src/features/home/index.ts +++ b/src/features/home/index.ts @@ -13,7 +13,6 @@ export type { CalendarViewData, } from '@/features/home/user/types/schedule' export { - getMonthlySchedules, - getWeeklySchedules, - getDailySchedules, + getSelfSchedule, + adaptScheduleResponse, } from '@/features/home/user/api/schedule' diff --git a/src/features/home/user/api/schedule.ts b/src/features/home/user/api/schedule.ts index 82bccd5..769f29f 100644 --- a/src/features/home/user/api/schedule.ts +++ b/src/features/home/user/api/schedule.ts @@ -13,9 +13,10 @@ import { toTimeLabel, } from '@/features/home/user/lib/date' -interface PeriodQueryParams { - startDate: string - endDate: string +export interface SelfScheduleQueryParams { + year?: number + month?: number + day?: number } function mapToCalendarEvent( @@ -47,19 +48,28 @@ export function adaptScheduleResponse( } } -async function fetchSchedule( - endpoint: string, - params: PeriodQueryParams +export async function getSelfSchedule( + params?: SelfScheduleQueryParams ): Promise { + const { year, month, day } = params ?? {} + try { - const response = await axiosInstance.get(endpoint, { - params, - }) + const response = await axiosInstance.get( + '/app/schedules/self', + { + params: { + ...(year !== undefined && { year }), + ...(month !== undefined && { month }), + ...(day !== undefined && { day }), + }, + } + ) return response.data } catch (error) { if (axios.isAxiosError(error)) { const errorData: ErrorResponse = error.response?.data ?? {} - const message = errorData.message ?? '스케줄 조회 중 오류가 발생했습니다.' + const message = + errorData.message ?? '스케줄 조회 중 오류가 발생했습니다.' const apiError = new Error(message) as ApiError & Error apiError.data = errorData throw apiError @@ -67,18 +77,3 @@ async function fetchSchedule( throw new Error('스케줄 조회 중 오류가 발생했습니다.') } } - -export async function getMonthlySchedules(params: PeriodQueryParams) { - const response = await fetchSchedule('/app/schedules/self/monthly', params) - return adaptScheduleResponse(response) -} - -export async function getWeeklySchedules(params: PeriodQueryParams) { - const response = await fetchSchedule('/app/schedules/self/weekly', params) - return adaptScheduleResponse(response) -} - -export async function getDailySchedules(params: PeriodQueryParams) { - const response = await fetchSchedule('/app/schedules/self/daily', params) - return adaptScheduleResponse(response) -} diff --git a/src/features/home/user/hooks/useHomeScheduleViewModel.ts b/src/features/home/user/hooks/useHomeScheduleViewModel.ts new file mode 100644 index 0000000..db64a86 --- /dev/null +++ b/src/features/home/user/hooks/useHomeScheduleViewModel.ts @@ -0,0 +1,40 @@ +import { useCallback, useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import type { HomeCalendarMode } from '@/features/home/user/types/schedule' +import { + getSelfSchedule, + adaptScheduleResponse, +} from '@/features/home/user/api/schedule' +import { getScheduleParamsByMode } from '@/features/home/user/lib/date' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useHomeScheduleViewModel() { + const [mode, setMode] = useState('monthly') + const [baseDate, setBaseDate] = useState(() => new Date()) + + const params = getScheduleParamsByMode(baseDate, mode) + + const { data: rawData, isPending, error } = useQuery({ + queryKey: queryKeys.schedules.self(params), + queryFn: () => getSelfSchedule(params), + }) + + const calendarData = useMemo( + () => (rawData ? adaptScheduleResponse(rawData) : null), + [rawData] + ) + + const onDateChange = useCallback((nextDate: Date) => { + setBaseDate(nextDate) + }, []) + + return { + mode, + setMode, + baseDate, + calendarData, + isLoading: isPending, + error, + onDateChange, + } +} diff --git a/src/features/home/user/hooks/useScheduleListViewModel.ts b/src/features/home/user/hooks/useScheduleListViewModel.ts new file mode 100644 index 0000000..499ce7e --- /dev/null +++ b/src/features/home/user/hooks/useScheduleListViewModel.ts @@ -0,0 +1,56 @@ +import { useCallback, useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { getSelfSchedule } from '@/features/home/user/api/schedule' +import { mapToScheduleListItems } from '@/features/home/user/lib/date' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useScheduleListViewModel() { + const [currentYear, setCurrentYear] = useState( + () => new Date().getFullYear() + ) + const [currentMonth, setCurrentMonth] = useState( + () => new Date().getMonth() + 1 + ) + + const { data: rawData, isPending } = useQuery({ + queryKey: queryKeys.schedules.self({ year: currentYear, month: currentMonth }), + queryFn: () => getSelfSchedule({ year: currentYear, month: currentMonth }), + }) + + const schedules = useMemo( + () => mapToScheduleListItems(rawData?.data), + [rawData] + ) + + const handlePreviousMonth = useCallback(() => { + if (currentMonth === 1) { + setCurrentYear(y => y - 1) + setCurrentMonth(12) + } else { + setCurrentMonth(m => m - 1) + } + }, [currentMonth]) + + const handleNextMonth = useCallback(() => { + if (currentMonth === 12) { + setCurrentYear(y => y + 1) + setCurrentMonth(1) + } else { + setCurrentMonth(m => m + 1) + } + }, [currentMonth]) + + const handleScheduleClick = useCallback((id: string) => { + console.log('스케줄 클릭:', id) + }, []) + + return { + currentYear, + currentMonth, + schedules, + isLoading: isPending, + handlePreviousMonth, + handleNextMonth, + handleScheduleClick, + } +} diff --git a/src/features/home/user/lib/date.test.ts b/src/features/home/user/lib/date.test.ts index f05b8b2..cea0a26 100644 --- a/src/features/home/user/lib/date.test.ts +++ b/src/features/home/user/lib/date.test.ts @@ -8,7 +8,7 @@ import { getDayHours, getDurationHours, getMonthlyDateCells, - getRangeParamsByMode, + getScheduleParamsByMode, getWeekRangeLabel, getWeeklyDateCells, moveDateByMode, @@ -131,26 +131,28 @@ describe('getDailyHourTicks', () => { }) }) -describe('getRangeParamsByMode', () => { +describe('getScheduleParamsByMode', () => { const base = new Date(2026, 3, 15) - it('monthly이면 해당 월의 첫날·마지막날이다', () => { - expect(getRangeParamsByMode(base, 'monthly')).toEqual({ - startDate: '2026-04-01', - endDate: '2026-04-30', + it('monthly이면 year·month만 반환한다', () => { + expect(getScheduleParamsByMode(base, 'monthly')).toEqual({ + year: 2026, + month: 4, }) }) - it('weekly이면 해당 주 월요일~일요일이다', () => { - const { startDate, endDate } = getRangeParamsByMode(base, 'weekly') - expect(startDate <= endDate).toBe(true) - expect(startDate).toMatch(/^\d{4}-\d{2}-\d{2}$/) + it('weekly이면 year·month만 반환한다', () => { + expect(getScheduleParamsByMode(base, 'weekly')).toEqual({ + year: 2026, + month: 4, + }) }) - it('daily이면 하루 범위로 동일한 날짜다', () => { - expect(getRangeParamsByMode(base, 'daily')).toEqual({ - startDate: '2026-04-15', - endDate: '2026-04-15', + it('daily이면 year·month·day를 반환한다', () => { + expect(getScheduleParamsByMode(base, 'daily')).toEqual({ + year: 2026, + month: 4, + day: 15, }) }) }) diff --git a/src/features/home/user/lib/date.ts b/src/features/home/user/lib/date.ts index 188ceed..13793cc 100644 --- a/src/features/home/user/lib/date.ts +++ b/src/features/home/user/lib/date.ts @@ -12,6 +12,9 @@ import { import { ko } from 'date-fns/locale' import type { ScheduleDataDto } from '@/features/home/user/types/schedule' import type { HomeCalendarMode } from '@/features/home/user/types/schedule' +import type { SelfScheduleQueryParams } from '@/features/home/user/api/schedule' +import type { ScheduleListItem } from '@/features/home/user/types/scheduleList' +import { WEEKDAY_LABELS } from '@/features/home/user/constants/calendar' const ISO_DATE_LENGTH = 10 const ISO_TIME_START = 11 @@ -87,31 +90,53 @@ export function getDailyHourTicks() { ) } -export function getRangeParamsByMode(baseDate: Date, mode: HomeCalendarMode) { - if (mode === 'monthly') { - return { - startDate: format(startOfMonth(baseDate), 'yyyy-MM-dd'), - endDate: format(endOfMonth(baseDate), 'yyyy-MM-dd'), - } - } - - if (mode === 'weekly') { - return { - startDate: format( - startOfWeek(baseDate, { weekStartsOn: 1 }), - 'yyyy-MM-dd' - ), - endDate: format(endOfWeek(baseDate, { weekStartsOn: 1 }), 'yyyy-MM-dd'), - } +export function getScheduleParamsByMode( + baseDate: Date, + mode: HomeCalendarMode +): SelfScheduleQueryParams { + const year = baseDate.getFullYear() + const month = baseDate.getMonth() + 1 + if (mode === 'daily') { + return { year, month, day: baseDate.getDate() } } + return { year, month } +} - const day = format(baseDate, 'yyyy-MM-dd') +export function formatScheduleTimeRange( + startIso: string, + endIso: string +): { time: string; hours: string } { + const durationHours = getDurationHours(startIso, endIso) + const hoursLabel = Number.isInteger(durationHours) + ? `${durationHours}시간` + : `${durationHours.toFixed(1)}시간` return { - startDate: day, - endDate: day, + time: `${toTimeLabel(startIso)} ~ ${toTimeLabel(endIso)}`, + hours: hoursLabel, } } +export function mapToScheduleListItems( + data: ScheduleDataDto | undefined +): ScheduleListItem[] { + if (!data) return [] + return data.schedules.map(schedule => { + const start = new Date(schedule.startDateTime) + const { time, hours } = formatScheduleTimeRange( + schedule.startDateTime, + schedule.endDateTime + ) + return { + id: String(schedule.shiftId), + day: WEEKDAY_LABELS[start.getDay()], + date: String(start.getDate()), + workplace: schedule.workspace.workspaceName, + time, + hours, + } + }) +} + export function moveDateByMode( baseDate: Date, direction: 'prev' | 'next', diff --git a/src/features/home/user/types/scheduleList.ts b/src/features/home/user/types/scheduleList.ts new file mode 100644 index 0000000..9c3c1c7 --- /dev/null +++ b/src/features/home/user/types/scheduleList.ts @@ -0,0 +1,8 @@ +export interface ScheduleListItem { + id: string + day: string + date: string + workplace: string + time: string + hours: string +} diff --git a/src/pages/user/home/index.tsx b/src/pages/user/home/index.tsx index a3d43a3..7eb7d25 100644 --- a/src/pages/user/home/index.tsx +++ b/src/pages/user/home/index.tsx @@ -5,6 +5,7 @@ import { } from '@/features/home' import type { WorkingStoreItem } from '@/features/home/user/ui/WorkingStoreCard' import type { AppliedStoreItem } from '@/features/home/user/ui/AppliedStoreList' +import { useHomeScheduleViewModel } from '@/features/home/user/hooks/useHomeScheduleViewModel' import { Navbar } from '@/shared/ui/common/Navbar' import { useNavigate } from 'react-router-dom' @@ -31,6 +32,8 @@ const APPLIED_STORES: AppliedStoreItem[] = [ export function UserHomePage() { const navigate = useNavigate() + const { mode, baseDate, calendarData, isLoading, onDateChange } = + useHomeScheduleViewModel() return (
@@ -39,11 +42,11 @@ export function UserHomePage() {
{}} + mode={mode} + baseDate={baseDate} + data={calendarData} + isLoading={isLoading} + onDateChange={onDateChange} /> void } diff --git a/src/pages/user/schedule/hooks/useSchedule.ts b/src/pages/user/schedule/hooks/useSchedule.ts deleted file mode 100644 index 54e40d6..0000000 --- a/src/pages/user/schedule/hooks/useSchedule.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useScheduleStore } from '@/shared/stores/useScheduleStore' - -/** - * 스케줄 페이지의 UI 상태(연·월)와 핸들러만 제공. - * 데이터 조회(useSelfScheduleQuery)는 데이터를 소비하는 페이지에서 호출. - */ -export function useSchedule() { - const currentYear = useScheduleStore(state => state.currentYear) - const currentMonth = useScheduleStore(state => state.currentMonth) - const goPrevMonth = useScheduleStore(state => state.goPrevMonth) - const goNextMonth = useScheduleStore(state => state.goNextMonth) - - const handlePreviousMonth = () => { - goPrevMonth() - } - - const handleNextMonth = () => { - goNextMonth() - } - - const handleScheduleClick = (id: string) => { - console.log('스케줄 클릭:', id) - // 스케줄 상세 페이지 이동 (필요시 구현) - } - - return { - currentYear, - currentMonth, - handlePreviousMonth, - handleNextMonth, - handleScheduleClick, - } -} diff --git a/src/pages/user/schedule/index.tsx b/src/pages/user/schedule/index.tsx index 290bbd4..7c6358f 100644 --- a/src/pages/user/schedule/index.tsx +++ b/src/pages/user/schedule/index.tsx @@ -1,7 +1,6 @@ -import type { ScheduleItem as ScheduleItemType } from '@/shared/stores/useScheduleStore' -import { useSelfScheduleQuery } from '@/shared/hooks/useSelfScheduleQuery' +import type { ScheduleListItem } from '@/features/home/user/types/scheduleList' +import { useScheduleListViewModel } from '@/features/home/user/hooks/useScheduleListViewModel' import { ScheduleItem } from './components/ScheduleItem' -import { useSchedule } from './hooks/useSchedule' import { ChevronLeftIcon } from '@/assets/icons/ChevronLeftIcon' import { ChevronRightIcon } from '@/assets/icons/ChevronRightIcon' import { CalendarEmptyIcon } from '@/assets/icons/CalendarEmptyIcon' @@ -11,15 +10,12 @@ export function SchedulePage() { const { currentYear, currentMonth, + schedules, + isLoading, handlePreviousMonth, handleNextMonth, handleScheduleClick, - } = useSchedule() - - const { schedules, isLoading } = useSelfScheduleQuery({ - year: currentYear, - month: currentMonth, - }) + } = useScheduleListViewModel() return (
@@ -61,7 +57,7 @@ export function SchedulePage() { {schedules.length > 0 ? (
- {schedules.map((schedule: ScheduleItemType) => ( + {schedules.map((schedule: ScheduleListItem) => ( - -type GetSelfScheduleParams = { - year?: number - month?: number - day?: number -} - -export async function getSelfSchedule( - params?: GetSelfScheduleParams -): Promise { - const { year, month, day } = params ?? {} - - try { - const response = await axiosInstance.get( - '/app/schedules/self', - { - params: { - ...(year !== undefined && { year }), - ...(month !== undefined && { month }), - ...(day !== undefined && { day }), - }, - } - ) - return response.data - } catch (error) { - if (axios.isAxiosError(error)) { - const errorData: ErrorResponse = error.response?.data ?? {} - const message = - errorData.message ?? '나의 근무 스케줄 조회 중 오류가 발생했습니다.' - const apiError = new Error(message) as ApiError & Error - apiError.data = errorData - throw apiError - } - throw new Error('나의 근무 스케줄 조회 중 오류가 발생했습니다.') - } -} diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 3ca9b48..16c38cb 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1,12 +1,2 @@ -export { - useSelfScheduleQuery, - SELF_SCHEDULE_QUERY_KEY, -} from './useSelfScheduleQuery' -export { - useWorkspaceWorkersQuery, - WORKSPACE_WORKERS_QUERY_KEY, -} from './useWorkspaceWorkersQuery' -export { - useWorkspaceManagersQuery, - WORKSPACE_MANAGERS_QUERY_KEY, -} from './useWorkspaceManagersQuery' +export { useWorkspaceWorkersQuery } from './useWorkspaceWorkersQuery' +export { useWorkspaceManagersQuery } from './useWorkspaceManagersQuery' diff --git a/src/shared/hooks/useSelfScheduleQuery.ts b/src/shared/hooks/useSelfScheduleQuery.ts deleted file mode 100644 index d9c283c..0000000 --- a/src/shared/hooks/useSelfScheduleQuery.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { useMemo } from 'react' -import { useQuery } from '@tanstack/react-query' -import { getSelfSchedule } from '@/shared/api/schedule' -import type { ScheduleItem } from '@/shared/stores/useScheduleStore' - -const DAY_LABELS = ['일', '월', '화', '수', '목', '금', '토'] as const - -function formatTimeRange( - startIso: string, - endIso: string -): { time: string; hours: string } { - const start = new Date(startIso) - const end = new Date(endIso) - - const pad = (value: number) => value.toString().padStart(2, '0') - - const startTime = `${pad(start.getHours())}:${pad(start.getMinutes())}` - const endTime = `${pad(end.getHours())}:${pad(end.getMinutes())}` - - const diffMs = end.getTime() - start.getTime() - const diffHours = Math.max(diffMs / (1000 * 60 * 60), 0) - - const hoursLabel = Number.isInteger(diffHours) - ? `${diffHours}시간` - : `${diffHours.toFixed(1)}시간` - - return { - time: `${startTime} ~ ${endTime}`, - hours: hoursLabel, - } -} - -function mapSchedulesToItems( - data: Awaited>['data'] | undefined -): ScheduleItem[] { - if (!data) return [] - return data.schedules.map(schedule => { - const start = new Date(schedule.startDateTime) - const dayIndex = start.getDay() - const { time, hours } = formatTimeRange( - schedule.startDateTime, - schedule.endDateTime - ) - return { - id: String(schedule.shiftId), - day: DAY_LABELS[dayIndex], - date: String(start.getDate()), - workplace: schedule.workspace.workspaceName, - time, - hours, - } - }) -} - -export const SELF_SCHEDULE_QUERY_KEY = ['schedule', 'self'] as const - -type SelfScheduleQueryParams = { - year: number - month: number - day?: number -} - -/** - * 나의 근무 스케줄 조회 - * 파라미터 조합에 따라 조회가 달라집니다. -- 인자 없음: 이번 주 스케줄 조회 -- year, month: 해당 월 스케줄 조회 -- year, month, day: 해당 일 스케줄 조회 - * - * @param params.year 조회할 연도 - * @param params.month 조회할 월 - * @param params.day 조회할 일 (일별 조회 시 사용) - * @returns - */ -export function useSelfScheduleQuery(params: SelfScheduleQueryParams) { - const { year, month, day } = params - - const { data, isPending, error } = useQuery({ - queryKey: [ - ...SELF_SCHEDULE_QUERY_KEY, - year, - month, - ...(day !== undefined ? [day] : []), - ], - queryFn: async () => { - const res = await getSelfSchedule({ - year, - month, - ...(day !== undefined && { day }), - }) - return res.data - }, - }) - - const schedules = useMemo(() => mapSchedulesToItems(data), [data]) - - return { - schedules, - isLoading: isPending, - error, - rawData: data, - } -} diff --git a/src/shared/hooks/useWorkspaceManagersQuery.ts b/src/shared/hooks/useWorkspaceManagersQuery.ts index 1f11fc5..510b066 100644 --- a/src/shared/hooks/useWorkspaceManagersQuery.ts +++ b/src/shared/hooks/useWorkspaceManagersQuery.ts @@ -4,8 +4,7 @@ import { getWorkspaceManagers, type WorkspaceManagersResponse, } from '@/shared/api/workspaceMembers' - -export const WORKSPACE_MANAGERS_QUERY_KEY = ['workspace', 'managers'] as const +import { queryKeys } from '@/shared/lib/queryKeys' type WorkspaceManagersQueryParams = { workspaceId?: number @@ -21,7 +20,7 @@ export function useWorkspaceManagersQuery( const { workspaceId, cursor, pageSize } = params const { data, isPending, error } = useQuery({ - queryKey: [...WORKSPACE_MANAGERS_QUERY_KEY, workspaceId, cursor, pageSize], + queryKey: queryKeys.workspace.managers(workspaceId, cursor, pageSize), queryFn: async () => { if (!workspaceId) { throw new Error('workspaceId는 필수입니다.') diff --git a/src/shared/hooks/useWorkspaceWorkersQuery.ts b/src/shared/hooks/useWorkspaceWorkersQuery.ts index a45aaaf..181e835 100644 --- a/src/shared/hooks/useWorkspaceWorkersQuery.ts +++ b/src/shared/hooks/useWorkspaceWorkersQuery.ts @@ -4,8 +4,7 @@ import { getWorkspaceWorkers, type WorkspaceWorkersResponse, } from '@/shared/api/workspaceMembers' - -export const WORKSPACE_WORKERS_QUERY_KEY = ['workspace', 'workers'] as const +import { queryKeys } from '@/shared/lib/queryKeys' type WorkspaceWorkersQueryParams = { workspaceId?: number @@ -19,7 +18,7 @@ export function useWorkspaceWorkersQuery(params: WorkspaceWorkersQueryParams) { const { workspaceId, cursor, pageSize } = params const { data, isPending, error } = useQuery({ - queryKey: [...WORKSPACE_WORKERS_QUERY_KEY, workspaceId, cursor, pageSize], + queryKey: queryKeys.workspace.workers(workspaceId, cursor, pageSize), queryFn: async () => { if (!workspaceId) { throw new Error('workspaceId는 필수입니다.') diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts new file mode 100644 index 0000000..e8c15e7 --- /dev/null +++ b/src/shared/lib/queryKeys.ts @@ -0,0 +1,21 @@ +import type { SelfScheduleQueryParams } from '@/features/home/user/api/schedule' + +export const queryKeys = { + schedules: { + all: ['schedules'] as const, + self: (params: SelfScheduleQueryParams) => + ['schedules', 'self', params] as const, + }, + workspace: { + workers: ( + workspaceId?: number, + cursor?: string, + pageSize?: number + ) => ['workspace', 'workers', workspaceId, cursor, pageSize] as const, + managers: ( + workspaceId?: number, + cursor?: string, + pageSize?: number + ) => ['workspace', 'managers', workspaceId, cursor, pageSize] as const, + }, +} as const diff --git a/src/shared/stores/useScheduleStore.ts b/src/shared/stores/useScheduleStore.ts deleted file mode 100644 index 7ab06ab..0000000 --- a/src/shared/stores/useScheduleStore.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { create } from 'zustand' - -export interface ScheduleItem { - id: string - day: string - date: string - workplace: string - time: string - hours: string -} - -export interface ScheduleState { - schedules: ScheduleItem[] - isLoading: boolean - hasMore: boolean - nextCursor: string | null - isLoadingMore: boolean - currentYear: number - currentMonth: number - setSchedules: (schedules: ScheduleItem[]) => void - setLoading: (loading: boolean) => void - setHasMore: (hasMore: boolean) => void - setNextCursor: (cursor: string | null) => void - setIsLoadingMore: (loading: boolean) => void - setCurrentYear: (year: number) => void - setCurrentMonth: (month: number) => void - goPrevMonth: () => { year: number; month: number } - goNextMonth: () => { year: number; month: number } - setYearMonth: (year: number, month: number) => void -} - -export const useScheduleStore = create((set, get) => { - return { - schedules: [], - isLoading: true, - hasMore: true, - nextCursor: null, - isLoadingMore: false, - currentYear: new Date().getFullYear(), - currentMonth: new Date().getMonth() + 1, - - setSchedules: schedules => set({ schedules }), - setLoading: loading => set({ isLoading: loading }), - setHasMore: hasMore => set({ hasMore }), - setNextCursor: nextCursor => set({ nextCursor }), - setIsLoadingMore: isLoadingMore => set({ isLoadingMore }), - setCurrentYear: currentYear => set({ currentYear }), - setCurrentMonth: currentMonth => { - if (currentMonth < 1 || currentMonth > 12) { - throw new RangeError('month must be between 1 and 12') - } - set({ currentMonth }) - }, - - goPrevMonth: () => { - const { currentYear, currentMonth } = get() - const month = currentMonth === 1 ? 12 : currentMonth - 1 - const year = currentMonth === 1 ? currentYear - 1 : currentYear - set({ currentYear: year, currentMonth: month }) - return { year, month } - }, - - goNextMonth: () => { - const { currentYear, currentMonth } = get() - const month = currentMonth === 12 ? 1 : currentMonth + 1 - const year = currentMonth === 12 ? currentYear + 1 : currentYear - set({ currentYear: year, currentMonth: month }) - return { year, month } - }, - - setYearMonth: (year, month) => { - if (month < 1 || month > 12) { - throw new RangeError('month must be between 1 and 12') - } - set({ currentYear: year, currentMonth: month }) - }, - } -}) From b5845bd09e1dd7778d55be49cea1ca06ae7b07d1 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Mon, 13 Apr 2026 02:00:20 +0900 Subject: [PATCH 08/39] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/user/api/schedule.ts | 3 +-- .../home/user/hooks/useHomeScheduleViewModel.ts | 6 +++++- .../home/user/hooks/useScheduleListViewModel.ts | 9 +++++---- src/shared/lib/queryKeys.ts | 14 ++++---------- 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/features/home/user/api/schedule.ts b/src/features/home/user/api/schedule.ts index 769f29f..5cabcdb 100644 --- a/src/features/home/user/api/schedule.ts +++ b/src/features/home/user/api/schedule.ts @@ -68,8 +68,7 @@ export async function getSelfSchedule( } catch (error) { if (axios.isAxiosError(error)) { const errorData: ErrorResponse = error.response?.data ?? {} - const message = - errorData.message ?? '스케줄 조회 중 오류가 발생했습니다.' + const message = errorData.message ?? '스케줄 조회 중 오류가 발생했습니다.' const apiError = new Error(message) as ApiError & Error apiError.data = errorData throw apiError diff --git a/src/features/home/user/hooks/useHomeScheduleViewModel.ts b/src/features/home/user/hooks/useHomeScheduleViewModel.ts index db64a86..0192e8a 100644 --- a/src/features/home/user/hooks/useHomeScheduleViewModel.ts +++ b/src/features/home/user/hooks/useHomeScheduleViewModel.ts @@ -14,7 +14,11 @@ export function useHomeScheduleViewModel() { const params = getScheduleParamsByMode(baseDate, mode) - const { data: rawData, isPending, error } = useQuery({ + const { + data: rawData, + isPending, + error, + } = useQuery({ queryKey: queryKeys.schedules.self(params), queryFn: () => getSelfSchedule(params), }) diff --git a/src/features/home/user/hooks/useScheduleListViewModel.ts b/src/features/home/user/hooks/useScheduleListViewModel.ts index 499ce7e..ecec6bc 100644 --- a/src/features/home/user/hooks/useScheduleListViewModel.ts +++ b/src/features/home/user/hooks/useScheduleListViewModel.ts @@ -5,15 +5,16 @@ import { mapToScheduleListItems } from '@/features/home/user/lib/date' import { queryKeys } from '@/shared/lib/queryKeys' export function useScheduleListViewModel() { - const [currentYear, setCurrentYear] = useState( - () => new Date().getFullYear() - ) + const [currentYear, setCurrentYear] = useState(() => new Date().getFullYear()) const [currentMonth, setCurrentMonth] = useState( () => new Date().getMonth() + 1 ) const { data: rawData, isPending } = useQuery({ - queryKey: queryKeys.schedules.self({ year: currentYear, month: currentMonth }), + queryKey: queryKeys.schedules.self({ + year: currentYear, + month: currentMonth, + }), queryFn: () => getSelfSchedule({ year: currentYear, month: currentMonth }), }) diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index e8c15e7..0a7c5b3 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -7,15 +7,9 @@ export const queryKeys = { ['schedules', 'self', params] as const, }, workspace: { - workers: ( - workspaceId?: number, - cursor?: string, - pageSize?: number - ) => ['workspace', 'workers', workspaceId, cursor, pageSize] as const, - managers: ( - workspaceId?: number, - cursor?: string, - pageSize?: number - ) => ['workspace', 'managers', workspaceId, cursor, pageSize] as const, + workers: (workspaceId?: number, cursor?: string, pageSize?: number) => + ['workspace', 'workers', workspaceId, cursor, pageSize] as const, + managers: (workspaceId?: number, cursor?: string, pageSize?: number) => + ['workspace', 'managers', workspaceId, cursor, pageSize] as const, }, } as const From 87bdc0464ccfd1218deceed11e5b678f3c521702 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 15 Apr 2026 23:38:15 +0900 Subject: [PATCH 09/39] =?UTF-8?q?feat:=20=ED=98=84=EC=9E=AC=20=EA=B7=BC?= =?UTF-8?q?=EB=AC=B4=EC=A4=91=EC=9D=B8=20=EC=97=85=EC=9E=A5=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20api=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/user/api/workspace.ts | 36 +++++++ .../home/user/hooks/useWorkspacesViewModel.ts | 35 +++++++ src/features/home/user/types/workspace.ts | 35 +++++++ src/pages/user/workspace/index.tsx | 99 ++++++++++--------- src/shared/lib/queryKeys.ts | 2 + 5 files changed, 160 insertions(+), 47 deletions(-) create mode 100644 src/features/home/user/api/workspace.ts create mode 100644 src/features/home/user/hooks/useWorkspacesViewModel.ts create mode 100644 src/features/home/user/types/workspace.ts diff --git a/src/features/home/user/api/workspace.ts b/src/features/home/user/api/workspace.ts new file mode 100644 index 0000000..51f744a --- /dev/null +++ b/src/features/home/user/api/workspace.ts @@ -0,0 +1,36 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + WorkspaceItem, + WorkspaceListApiResponse, + WorkspaceListQueryParams, +} from '@/features/home/user/types/workspace' + +function mapToWorkspaceItem(dto: WorkspaceListApiResponse['data']['data'][number]): WorkspaceItem { + return { + workspaceId: dto.workspaceId, + businessName: dto.businessName, + employedAt: dto.employedAt, + nextShiftDateTime: dto.nextShiftDateTime, + } +} + +export async function getMyWorkspaces( + params: WorkspaceListQueryParams +): Promise { + const response = await axiosInstance.get( + '/app/users/me/workspaces', + { + params: { + pageSize: params.pageSize, + ...(params.cursor !== undefined && { cursor: params.cursor }), + }, + } + ) + return response.data +} + +export function adaptWorkspaceListResponse( + response: WorkspaceListApiResponse +): WorkspaceItem[] { + return response.data.data.map(mapToWorkspaceItem) +} diff --git a/src/features/home/user/hooks/useWorkspacesViewModel.ts b/src/features/home/user/hooks/useWorkspacesViewModel.ts new file mode 100644 index 0000000..4e67566 --- /dev/null +++ b/src/features/home/user/hooks/useWorkspacesViewModel.ts @@ -0,0 +1,35 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { adaptWorkspaceListResponse, getMyWorkspaces } from '@/features/home/user/api/workspace' +import { queryKeys } from '@/shared/lib/queryKeys' + +const PAGE_SIZE = 10 + +export function useWorkspacesViewModel() { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } = + useInfiniteQuery({ + queryKey: queryKeys.workspace.list({ pageSize: PAGE_SIZE }), + queryFn: async ({ pageParam }) => { + const response = await getMyWorkspaces({ + pageSize: PAGE_SIZE, + cursor: pageParam as string | undefined, + }) + return response + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => { + const cursor = lastPage.data.page.cursor + return cursor || undefined + }, + }) + + const workspaces = data?.pages.flatMap(page => adaptWorkspaceListResponse(page)) ?? [] + + return { + workspaces, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } +} diff --git a/src/features/home/user/types/workspace.ts b/src/features/home/user/types/workspace.ts new file mode 100644 index 0000000..2679b58 --- /dev/null +++ b/src/features/home/user/types/workspace.ts @@ -0,0 +1,35 @@ +import type { CommonApiResponse } from '@/shared/types/common' + +// DTO +export interface WorkspaceItemDto { + workspaceId: number + businessName: string + employedAt: string + nextShiftDateTime: string +} + +export interface WorkspacePageDto { + cursor: string + pageSize: number + totalCount: number +} + +export interface WorkspaceListDto { + page: WorkspacePageDto + data: WorkspaceItemDto[] +} + +export type WorkspaceListApiResponse = CommonApiResponse + +// UI Model +export interface WorkspaceItem { + workspaceId: number + businessName: string + employedAt: string + nextShiftDateTime: string +} + +export interface WorkspaceListQueryParams { + cursor?: string + pageSize: number +} diff --git a/src/pages/user/workspace/index.tsx b/src/pages/user/workspace/index.tsx index 238a55d..2a616d4 100644 --- a/src/pages/user/workspace/index.tsx +++ b/src/pages/user/workspace/index.tsx @@ -1,60 +1,65 @@ import { useNavigate } from 'react-router-dom' import { Navbar } from '@/shared/ui/common/Navbar' -import { - WorkingStoreCard, - type WorkingStoreItem, -} from '@/features/home/user/ui/WorkingStoreCard' - -const DUMMY_STORES: WorkingStoreItem[] = [ - { - workspaceId: 1, - businessName: '출근하기 싫은 가게 부천점', - employedAt: '2024-01-01', - nextShiftDateTime: '2026-01-09T09:00:00', - }, - { - workspaceId: 2, - businessName: '일하기 싫은 가게 고척점', - employedAt: '2024-03-01', - nextShiftDateTime: '2026-01-11T14:00:00', - }, - { - workspaceId: 3, - businessName: '초 메가커피 신용산 레미안', - employedAt: '2024-06-01', - nextShiftDateTime: '2026-02-09T10:00:00', - }, - { - workspaceId: 4, - businessName: '동양미래대학교 서비스', - employedAt: '2024-09-01', - nextShiftDateTime: '2026-02-09T10:00:00', - }, - { - workspaceId: 5, - businessName: '집게리아', - employedAt: '2025-01-01', - nextShiftDateTime: '2026-02-09T10:00:00', - }, -] +import { WorkingStoreCard } from '@/features/home/user/ui/WorkingStoreCard' +import { useWorkspacesViewModel } from '@/features/home/user/hooks/useWorkspacesViewModel' export function WorkspacePage() { const navigate = useNavigate() + const { workspaces, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } = + useWorkspacesViewModel() + + if (isError) { + return ( +
+ +
+

+ 데이터를 불러오는 데 실패했습니다. +

+
+
+ ) + } return (
- {DUMMY_STORES.map(store => ( - - ))} + {isLoading ? ( +
+

로딩 중...

+
+ ) : ( + <> + {workspaces.map(store => ( + + ))} + {workspaces.length === 0 && ( +
+

+ 근무중인 가게가 없습니다. +

+
+ )} + {hasNextPage && ( + + )} + + )}
) diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index 0a7c5b3..e1ffb07 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -11,5 +11,7 @@ export const queryKeys = { ['workspace', 'workers', workspaceId, cursor, pageSize] as const, managers: (workspaceId?: number, cursor?: string, pageSize?: number) => ['workspace', 'managers', workspaceId, cursor, pageSize] as const, + list: (params?: { pageSize: number }) => + ['workspace', 'list', params] as const, }, } as const From 3cebd769e713f781f111b9a4cbb6ce49024ab205 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Thu, 16 Apr 2026 00:43:48 +0900 Subject: [PATCH 10/39] =?UTF-8?q?refactor:=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.tsx | 14 +++++++------- src/pages/login/index.tsx | 2 +- src/pages/user/home/index.tsx | 4 ++-- src/pages/user/workspace/index.tsx | 2 +- src/shared/api/auth.ts | 6 +++--- src/shared/stores/useDocStore.ts | 2 +- src/shared/ui/common/Docbar.tsx | 2 +- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 466a91c..a4b9f1b 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -61,22 +61,22 @@ export function App() { } /> - } /> + } /> } /> - } /> + } /> } /> - } /> + } /> }> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index f6bd1b9..3f55cfb 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -22,7 +22,7 @@ export function LoginPage() { if (scope === 'MANAGER') { navigate('/main', { replace: true }) } else { - navigate('/job-lookup-map', { replace: true }) + navigate('/user/job-lookup-map', { replace: true }) } } }, [isLoggedIn, scope, token, navigate]) diff --git a/src/pages/user/home/index.tsx b/src/pages/user/home/index.tsx index 7eb7d25..5ad896c 100644 --- a/src/pages/user/home/index.tsx +++ b/src/pages/user/home/index.tsx @@ -51,12 +51,12 @@ export function UserHomePage() { navigate('/workspace')} + onMoreClick={() => navigate('/user/workspace')} /> navigate('/applied-stores')} + onMoreClick={() => navigate('/user/applied-stores')} />
diff --git a/src/pages/user/workspace/index.tsx b/src/pages/user/workspace/index.tsx index 2a616d4..c52e5a0 100644 --- a/src/pages/user/workspace/index.tsx +++ b/src/pages/user/workspace/index.tsx @@ -36,7 +36,7 @@ export function WorkspacePage() { key={store.workspaceId} type="button" className="rounded-2xl bg-white py-[11px] text-left" - onClick={() => navigate(`/workspace/${store.workspaceId}`)} + onClick={() => navigate(`/user/workspace/${store.workspaceId}`)} > diff --git a/src/shared/api/auth.ts b/src/shared/api/auth.ts index c2ee514..a7babe4 100644 --- a/src/shared/api/auth.ts +++ b/src/shared/api/auth.ts @@ -107,7 +107,7 @@ export async function loginIDPW( if (scope === 'MANAGER') { navigate('/main', { replace: true }) } else { - navigate('/job-lookup-map', { replace: true }) + navigate('/user/job-lookup-map', { replace: true }) } return loginResponse @@ -158,7 +158,7 @@ export async function loginSocial( if (scope === 'MANAGER') { navigate('/main', { replace: true }) } else { - navigate('/job-lookup-map', { replace: true }) + navigate('/user/job-lookup-map', { replace: true }) } return result @@ -327,7 +327,7 @@ export async function signup( if (scope === 'MANAGER') { navigate('/main', { replace: true }) } else { - navigate('/job-lookup-map', { replace: true }) + navigate('/user/job-lookup-map', { replace: true }) } return signupResponse diff --git a/src/shared/stores/useDocStore.ts b/src/shared/stores/useDocStore.ts index 8bf6ff7..c6a1138 100644 --- a/src/shared/stores/useDocStore.ts +++ b/src/shared/stores/useDocStore.ts @@ -8,7 +8,7 @@ const PATHNAME_TAB_MAP: Array<{ matcher: RegExp; tab: TabKey }> = [ { matcher: /(^|\/)repute(\/|$)/, tab: 'repute' }, { matcher: /(^|\/)search(\/|$)/, tab: 'search' }, { matcher: /^\/manager\/home/, tab: 'home' }, - { matcher: /^\/job-lookup-map/, tab: 'search' }, + { matcher: /^\/user\/job-lookup-map/, tab: 'search' }, ] const createSelectedTab = (activeTab?: TabKey) => ({ diff --git a/src/shared/ui/common/Docbar.tsx b/src/shared/ui/common/Docbar.tsx index 9627b29..b1f3b52 100644 --- a/src/shared/ui/common/Docbar.tsx +++ b/src/shared/ui/common/Docbar.tsx @@ -115,7 +115,7 @@ export function Docbar() { const pathByTab: Record = { home: '/manager/home', - search: '/job-lookup-map', + search: '/user/job-lookup-map', message: '/message', repute: '/repute', my: '/my', From cde8265bb0485e3828ac15a5e07fde7c54fbcac7 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Thu, 16 Apr 2026 00:59:14 +0900 Subject: [PATCH 11/39] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/index.ts | 16 ++++++++-------- .../hooks/useAppliedStoresViewModel.ts | 2 +- .../{ => applied-stores}/types/appliedStore.ts | 0 .../{ => applied-stores}/ui/AppliedStoreCard.tsx | 0 .../ui/AppliedStoreDetailModal.tsx | 2 +- .../{ => applied-stores}/ui/AppliedStoreList.tsx | 2 +- .../ui/AppliedStoreListItem.tsx | 0 .../home/user/{ => schedule}/api/schedule.ts | 4 ++-- .../user/{ => schedule}/constants/calendar.ts | 0 .../hooks/useDailyCalendarViewModel.ts | 6 +++--- .../hooks/useHomeScheduleViewModel.ts | 6 +++--- .../hooks/useMonthlyCalendarViewModel.ts | 8 ++++---- .../hooks/useMonthlyDateCellsState.ts | 2 +- .../hooks/useScheduleListViewModel.ts | 4 ++-- .../hooks/useWeeklyCalendarViewModel.ts | 4 ++-- .../home/user/{ => schedule}/lib/date.test.ts | 2 +- .../home/user/{ => schedule}/lib/date.ts | 10 +++++----- .../home/user/{ => schedule}/types/calendar.ts | 2 +- .../user/{ => schedule}/types/dailyCalendar.ts | 2 +- .../user/{ => schedule}/types/monthlyCalendar.ts | 4 ++-- .../home/user/{ => schedule}/types/schedule.ts | 0 .../user/{ => schedule}/types/scheduleList.ts | 0 .../user/{ => schedule}/types/weeklyCalendar.ts | 2 +- .../user/{ => schedule}/ui/DailyCalendar.tsx | 4 ++-- .../{ => schedule}/ui/HomeScheduleCalendar.tsx | 8 ++++---- .../user/{ => schedule}/ui/MonthlyCalendar.tsx | 6 +++--- .../user/{ => schedule}/ui/MonthlyDateCell.tsx | 2 +- .../user/{ => schedule}/ui/MonthlyDateGauge.tsx | 0 .../user/{ => schedule}/ui/WeeklyCalendar.tsx | 4 ++-- .../home/user/{ => workspace}/api/workspace.ts | 2 +- .../hooks/useWorkingStoreCardViewModel.ts | 2 +- .../hooks/useWorkingStoresListViewModel.ts | 2 +- .../hooks/useWorkspacesViewModel.ts | 2 +- .../home/user/{ => workspace}/types/workspace.ts | 0 .../user/{ => workspace}/ui/WorkingStoreCard.tsx | 2 +- .../{ => workspace}/ui/WorkingStoresList.tsx | 4 ++-- src/pages/user/applied-stores/index.tsx | 8 ++++---- src/pages/user/home/index.tsx | 6 +++--- .../user/schedule/components/ScheduleItem.tsx | 2 +- src/pages/user/schedule/index.tsx | 4 ++-- src/pages/user/workspace/index.tsx | 4 ++-- src/shared/lib/queryKeys.ts | 2 +- 42 files changed, 71 insertions(+), 71 deletions(-) rename src/features/home/user/{ => applied-stores}/hooks/useAppliedStoresViewModel.ts (97%) rename src/features/home/user/{ => applied-stores}/types/appliedStore.ts (100%) rename src/features/home/user/{ => applied-stores}/ui/AppliedStoreCard.tsx (100%) rename src/features/home/user/{ => applied-stores}/ui/AppliedStoreDetailModal.tsx (98%) rename src/features/home/user/{ => applied-stores}/ui/AppliedStoreList.tsx (93%) rename src/features/home/user/{ => applied-stores}/ui/AppliedStoreListItem.tsx (100%) rename src/features/home/user/{ => schedule}/api/schedule.ts (95%) rename src/features/home/user/{ => schedule}/constants/calendar.ts (100%) rename src/features/home/user/{ => schedule}/hooks/useDailyCalendarViewModel.ts (93%) rename src/features/home/user/{ => schedule}/hooks/useHomeScheduleViewModel.ts (81%) rename src/features/home/user/{ => schedule}/hooks/useMonthlyCalendarViewModel.ts (92%) rename src/features/home/user/{ => schedule}/hooks/useMonthlyDateCellsState.ts (95%) rename src/features/home/user/{ => schedule}/hooks/useScheduleListViewModel.ts (89%) rename src/features/home/user/{ => schedule}/hooks/useWeeklyCalendarViewModel.ts (92%) rename src/features/home/user/{ => schedule}/lib/date.test.ts (98%) rename src/features/home/user/{ => schedule}/lib/date.ts (91%) rename src/features/home/user/{ => schedule}/types/calendar.ts (57%) rename src/features/home/user/{ => schedule}/types/dailyCalendar.ts (85%) rename src/features/home/user/{ => schedule}/types/monthlyCalendar.ts (88%) rename src/features/home/user/{ => schedule}/types/schedule.ts (100%) rename src/features/home/user/{ => schedule}/types/scheduleList.ts (100%) rename src/features/home/user/{ => schedule}/types/weeklyCalendar.ts (82%) rename src/features/home/user/{ => schedule}/ui/DailyCalendar.tsx (97%) rename src/features/home/user/{ => schedule}/ui/HomeScheduleCalendar.tsx (78%) rename src/features/home/user/{ => schedule}/ui/MonthlyCalendar.tsx (93%) rename src/features/home/user/{ => schedule}/ui/MonthlyDateCell.tsx (92%) rename src/features/home/user/{ => schedule}/ui/MonthlyDateGauge.tsx (100%) rename src/features/home/user/{ => schedule}/ui/WeeklyCalendar.tsx (97%) rename src/features/home/user/{ => workspace}/api/workspace.ts (94%) rename src/features/home/user/{ => workspace}/hooks/useWorkingStoreCardViewModel.ts (91%) rename src/features/home/user/{ => workspace}/hooks/useWorkingStoresListViewModel.ts (69%) rename src/features/home/user/{ => workspace}/hooks/useWorkspacesViewModel.ts (96%) rename src/features/home/user/{ => workspace}/types/workspace.ts (100%) rename src/features/home/user/{ => workspace}/ui/WorkingStoreCard.tsx (96%) rename src/features/home/user/{ => workspace}/ui/WorkingStoresList.tsx (94%) diff --git a/src/features/home/index.ts b/src/features/home/index.ts index 3d3fc80..837371d 100644 --- a/src/features/home/index.ts +++ b/src/features/home/index.ts @@ -1,18 +1,18 @@ -export { HomeScheduleCalendar } from '@/features/home/user/ui/HomeScheduleCalendar' +export { HomeScheduleCalendar } from '@/features/home/user/schedule/ui/HomeScheduleCalendar' export { TodayWorkerList } from '@/features/home/manager/ui/TodayWorkerList' export { StoreWorkerListItem } from '@/features/home/manager/ui/StoreWorkerListItem' export { WorkspaceChangeCard } from '@/features/home/manager/ui/WorkspaceChangeCard' export { WorkspaceChangeList } from '@/features/home/manager/ui/WorkspaceChangeList' -export { AppliedStoreCard } from '@/features/home/user/ui/AppliedStoreCard' -export { AppliedStoreList } from '@/features/home/user/ui/AppliedStoreList' -export { AppliedStoreDetailModal } from '@/features/home/user/ui/AppliedStoreDetailModal' -export { WorkingStoresList } from '@/features/home/user/ui/WorkingStoresList' -export { WorkingStoreCard } from '@/features/home/user/ui/WorkingStoreCard' +export { AppliedStoreCard } from '@/features/home/user/applied-stores/ui/AppliedStoreCard' +export { AppliedStoreList } from '@/features/home/user/applied-stores/ui/AppliedStoreList' +export { AppliedStoreDetailModal } from '@/features/home/user/applied-stores/ui/AppliedStoreDetailModal' +export { WorkingStoresList } from '@/features/home/user/workspace/ui/WorkingStoresList' +export { WorkingStoreCard } from '@/features/home/user/workspace/ui/WorkingStoreCard' export type { HomeCalendarMode, CalendarViewData, -} from '@/features/home/user/types/schedule' +} from '@/features/home/user/schedule/types/schedule' export { getSelfSchedule, adaptScheduleResponse, -} from '@/features/home/user/api/schedule' +} from '@/features/home/user/schedule/api/schedule' diff --git a/src/features/home/user/hooks/useAppliedStoresViewModel.ts b/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts similarity index 97% rename from src/features/home/user/hooks/useAppliedStoresViewModel.ts rename to src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts index e72c1dc..0c6103a 100644 --- a/src/features/home/user/hooks/useAppliedStoresViewModel.ts +++ b/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts @@ -3,7 +3,7 @@ import type { ApplicationStatus, FilterType, AppliedStoreData, -} from '@/features/home/user/types/appliedStore' +} from '@/features/home/user/applied-stores/types/appliedStore' const FILTER_OPTIONS: { key: FilterType; label: string }[] = [ { key: 'completed', label: '지원 완료' }, diff --git a/src/features/home/user/types/appliedStore.ts b/src/features/home/user/applied-stores/types/appliedStore.ts similarity index 100% rename from src/features/home/user/types/appliedStore.ts rename to src/features/home/user/applied-stores/types/appliedStore.ts diff --git a/src/features/home/user/ui/AppliedStoreCard.tsx b/src/features/home/user/applied-stores/ui/AppliedStoreCard.tsx similarity index 100% rename from src/features/home/user/ui/AppliedStoreCard.tsx rename to src/features/home/user/applied-stores/ui/AppliedStoreCard.tsx diff --git a/src/features/home/user/ui/AppliedStoreDetailModal.tsx b/src/features/home/user/applied-stores/ui/AppliedStoreDetailModal.tsx similarity index 98% rename from src/features/home/user/ui/AppliedStoreDetailModal.tsx rename to src/features/home/user/applied-stores/ui/AppliedStoreDetailModal.tsx index 5f511b8..5b2ee50 100644 --- a/src/features/home/user/ui/AppliedStoreDetailModal.tsx +++ b/src/features/home/user/applied-stores/ui/AppliedStoreDetailModal.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react' import { WEEKDAY_LABELS, type AppliedApplicationDetail, -} from '@/features/home/user/types/appliedStore' +} from '@/features/home/user/applied-stores/types/appliedStore' interface AppliedStoreDetailModalProps { isOpen: boolean diff --git a/src/features/home/user/ui/AppliedStoreList.tsx b/src/features/home/user/applied-stores/ui/AppliedStoreList.tsx similarity index 93% rename from src/features/home/user/ui/AppliedStoreList.tsx rename to src/features/home/user/applied-stores/ui/AppliedStoreList.tsx index cb8d8ea..5fdc248 100644 --- a/src/features/home/user/ui/AppliedStoreList.tsx +++ b/src/features/home/user/applied-stores/ui/AppliedStoreList.tsx @@ -1,5 +1,5 @@ import { MoreButton } from '@/shared/ui/common/MoreButton' -import { AppliedStoreCard } from '@/features/home/user/ui/AppliedStoreCard' +import { AppliedStoreCard } from '@/features/home/user/applied-stores/ui/AppliedStoreCard' interface AppliedStoreItem { id: number | string diff --git a/src/features/home/user/ui/AppliedStoreListItem.tsx b/src/features/home/user/applied-stores/ui/AppliedStoreListItem.tsx similarity index 100% rename from src/features/home/user/ui/AppliedStoreListItem.tsx rename to src/features/home/user/applied-stores/ui/AppliedStoreListItem.tsx diff --git a/src/features/home/user/api/schedule.ts b/src/features/home/user/schedule/api/schedule.ts similarity index 95% rename from src/features/home/user/api/schedule.ts rename to src/features/home/user/schedule/api/schedule.ts index 5cabcdb..f791e88 100644 --- a/src/features/home/user/api/schedule.ts +++ b/src/features/home/user/schedule/api/schedule.ts @@ -6,12 +6,12 @@ import type { CalendarViewData, ScheduleApiResponse, ScheduleDataDto, -} from '@/features/home/user/types/schedule' +} from '@/features/home/user/schedule/types/schedule' import { getDurationHours, toDateKey, toTimeLabel, -} from '@/features/home/user/lib/date' +} from '@/features/home/user/schedule/lib/date' export interface SelfScheduleQueryParams { year?: number diff --git a/src/features/home/user/constants/calendar.ts b/src/features/home/user/schedule/constants/calendar.ts similarity index 100% rename from src/features/home/user/constants/calendar.ts rename to src/features/home/user/schedule/constants/calendar.ts diff --git a/src/features/home/user/hooks/useDailyCalendarViewModel.ts b/src/features/home/user/schedule/hooks/useDailyCalendarViewModel.ts similarity index 93% rename from src/features/home/user/hooks/useDailyCalendarViewModel.ts rename to src/features/home/user/schedule/hooks/useDailyCalendarViewModel.ts index 0afe30f..508cc25 100644 --- a/src/features/home/user/hooks/useDailyCalendarViewModel.ts +++ b/src/features/home/user/schedule/hooks/useDailyCalendarViewModel.ts @@ -5,12 +5,12 @@ import { DAILY_TIMELINE_END_HOUR, DAILY_TIMELINE_HEIGHT, DAILY_TIMELINE_START_HOUR, -} from '@/features/home/user/constants/calendar' +} from '@/features/home/user/schedule/constants/calendar' import type { DailyCalendarPropsBase, DailyCalendarViewModel, -} from '@/features/home/user/types/dailyCalendar' -import type { CalendarEvent } from '@/features/home/user/types/schedule' +} from '@/features/home/user/schedule/types/dailyCalendar' +import type { CalendarEvent } from '@/features/home/user/schedule/types/schedule' function getStatusStyle(status: string) { return DAILY_STATUS_STYLE_MAP[status] ?? 'bg-bg-dark text-text-90' diff --git a/src/features/home/user/hooks/useHomeScheduleViewModel.ts b/src/features/home/user/schedule/hooks/useHomeScheduleViewModel.ts similarity index 81% rename from src/features/home/user/hooks/useHomeScheduleViewModel.ts rename to src/features/home/user/schedule/hooks/useHomeScheduleViewModel.ts index 0192e8a..1b7026c 100644 --- a/src/features/home/user/hooks/useHomeScheduleViewModel.ts +++ b/src/features/home/user/schedule/hooks/useHomeScheduleViewModel.ts @@ -1,11 +1,11 @@ import { useCallback, useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import type { HomeCalendarMode } from '@/features/home/user/types/schedule' +import type { HomeCalendarMode } from '@/features/home/user/schedule/types/schedule' import { getSelfSchedule, adaptScheduleResponse, -} from '@/features/home/user/api/schedule' -import { getScheduleParamsByMode } from '@/features/home/user/lib/date' +} from '@/features/home/user/schedule/api/schedule' +import { getScheduleParamsByMode } from '@/features/home/user/schedule/lib/date' import { queryKeys } from '@/shared/lib/queryKeys' export function useHomeScheduleViewModel() { diff --git a/src/features/home/user/hooks/useMonthlyCalendarViewModel.ts b/src/features/home/user/schedule/hooks/useMonthlyCalendarViewModel.ts similarity index 92% rename from src/features/home/user/hooks/useMonthlyCalendarViewModel.ts rename to src/features/home/user/schedule/hooks/useMonthlyCalendarViewModel.ts index 6619cac..05b55f3 100644 --- a/src/features/home/user/hooks/useMonthlyCalendarViewModel.ts +++ b/src/features/home/user/schedule/hooks/useMonthlyCalendarViewModel.ts @@ -14,15 +14,15 @@ import { DATE_KEY_FORMAT, MONTH_LABEL_FORMAT, WEEKDAY_LABELS_MONDAY_FIRST, -} from '@/features/home/user/constants/calendar' -import { useMonthlyDateCellsState } from '@/features/home/user/hooks/useMonthlyDateCellsState' +} from '@/features/home/user/schedule/constants/calendar' +import { useMonthlyDateCellsState } from '@/features/home/user/schedule/hooks/useMonthlyDateCellsState' import type { MonthlyCalendarViewModel, MonthlyCellInput, MonthlyDayMetrics, MonthlyCalendarPropsBase, -} from '@/features/home/user/types/monthlyCalendar' -import type { CalendarViewData } from '@/features/home/user/types/schedule' +} from '@/features/home/user/schedule/types/monthlyCalendar' +import type { CalendarViewData } from '@/features/home/user/schedule/types/schedule' function getMonthlyCells(baseDate: Date): MonthlyCellInput[] { const monthStart = startOfMonth(baseDate) diff --git a/src/features/home/user/hooks/useMonthlyDateCellsState.ts b/src/features/home/user/schedule/hooks/useMonthlyDateCellsState.ts similarity index 95% rename from src/features/home/user/hooks/useMonthlyDateCellsState.ts rename to src/features/home/user/schedule/hooks/useMonthlyDateCellsState.ts index 25dde6f..7355b4d 100644 --- a/src/features/home/user/hooks/useMonthlyDateCellsState.ts +++ b/src/features/home/user/schedule/hooks/useMonthlyDateCellsState.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import type { UseMonthlyDateCellsStateParams } from '@/features/home/user/types/monthlyCalendar' +import type { UseMonthlyDateCellsStateParams } from '@/features/home/user/schedule/types/monthlyCalendar' export function useMonthlyDateCellsState({ cells, diff --git a/src/features/home/user/hooks/useScheduleListViewModel.ts b/src/features/home/user/schedule/hooks/useScheduleListViewModel.ts similarity index 89% rename from src/features/home/user/hooks/useScheduleListViewModel.ts rename to src/features/home/user/schedule/hooks/useScheduleListViewModel.ts index ecec6bc..bdf79ab 100644 --- a/src/features/home/user/hooks/useScheduleListViewModel.ts +++ b/src/features/home/user/schedule/hooks/useScheduleListViewModel.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { getSelfSchedule } from '@/features/home/user/api/schedule' -import { mapToScheduleListItems } from '@/features/home/user/lib/date' +import { getSelfSchedule } from '@/features/home/user/schedule/api/schedule' +import { mapToScheduleListItems } from '@/features/home/user/schedule/lib/date' import { queryKeys } from '@/shared/lib/queryKeys' export function useScheduleListViewModel() { diff --git a/src/features/home/user/hooks/useWeeklyCalendarViewModel.ts b/src/features/home/user/schedule/hooks/useWeeklyCalendarViewModel.ts similarity index 92% rename from src/features/home/user/hooks/useWeeklyCalendarViewModel.ts rename to src/features/home/user/schedule/hooks/useWeeklyCalendarViewModel.ts index d95a4c2..e07511c 100644 --- a/src/features/home/user/hooks/useWeeklyCalendarViewModel.ts +++ b/src/features/home/user/schedule/hooks/useWeeklyCalendarViewModel.ts @@ -1,10 +1,10 @@ import { format } from 'date-fns' import { useMemo } from 'react' -import { getWeeklyDateCells } from '@/features/home/user/lib/date' +import { getWeeklyDateCells } from '@/features/home/user/schedule/lib/date' import type { WeeklyCalendarPropsBase, WeeklyCalendarViewModel, -} from '@/features/home/user/types/weeklyCalendar' +} from '@/features/home/user/schedule/types/weeklyCalendar' function getSelectedDayIndex(baseDate: Date) { const day = baseDate.getDay() diff --git a/src/features/home/user/lib/date.test.ts b/src/features/home/user/schedule/lib/date.test.ts similarity index 98% rename from src/features/home/user/lib/date.test.ts rename to src/features/home/user/schedule/lib/date.test.ts index cea0a26..9cb576d 100644 --- a/src/features/home/user/lib/date.test.ts +++ b/src/features/home/user/schedule/lib/date.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ScheduleItemDto } from '@/features/home/user/types/schedule' +import type { ScheduleItemDto } from '@/features/home/user/schedule/types/schedule' import { StatusEnum } from '@/shared/types/enums' import { diff --git a/src/features/home/user/lib/date.ts b/src/features/home/user/schedule/lib/date.ts similarity index 91% rename from src/features/home/user/lib/date.ts rename to src/features/home/user/schedule/lib/date.ts index 13793cc..7c5f79f 100644 --- a/src/features/home/user/lib/date.ts +++ b/src/features/home/user/schedule/lib/date.ts @@ -10,11 +10,11 @@ import { startOfWeek, } from 'date-fns' import { ko } from 'date-fns/locale' -import type { ScheduleDataDto } from '@/features/home/user/types/schedule' -import type { HomeCalendarMode } from '@/features/home/user/types/schedule' -import type { SelfScheduleQueryParams } from '@/features/home/user/api/schedule' -import type { ScheduleListItem } from '@/features/home/user/types/scheduleList' -import { WEEKDAY_LABELS } from '@/features/home/user/constants/calendar' +import type { ScheduleDataDto } from '@/features/home/user/schedule/types/schedule' +import type { HomeCalendarMode } from '@/features/home/user/schedule/types/schedule' +import type { SelfScheduleQueryParams } from '@/features/home/user/schedule/api/schedule' +import type { ScheduleListItem } from '@/features/home/user/schedule/types/scheduleList' +import { WEEKDAY_LABELS } from '@/features/home/user/schedule/constants/calendar' const ISO_DATE_LENGTH = 10 const ISO_TIME_START = 11 diff --git a/src/features/home/user/types/calendar.ts b/src/features/home/user/schedule/types/calendar.ts similarity index 57% rename from src/features/home/user/types/calendar.ts rename to src/features/home/user/schedule/types/calendar.ts index 05af996..d1826bd 100644 --- a/src/features/home/user/types/calendar.ts +++ b/src/features/home/user/schedule/types/calendar.ts @@ -1,4 +1,4 @@ -import type { CalendarViewData } from '@/features/home/user/types/schedule' +import type { CalendarViewData } from '@/features/home/user/schedule/types/schedule' export interface BaseCalendarProps { baseDate: Date diff --git a/src/features/home/user/types/dailyCalendar.ts b/src/features/home/user/schedule/types/dailyCalendar.ts similarity index 85% rename from src/features/home/user/types/dailyCalendar.ts rename to src/features/home/user/schedule/types/dailyCalendar.ts index 99fa035..75fcfe7 100644 --- a/src/features/home/user/types/dailyCalendar.ts +++ b/src/features/home/user/schedule/types/dailyCalendar.ts @@ -1,4 +1,4 @@ -import type { BaseCalendarProps } from '@/features/home/user/types/calendar' +import type { BaseCalendarProps } from '@/features/home/user/schedule/types/calendar' export type DailyCalendarPropsBase = BaseCalendarProps diff --git a/src/features/home/user/types/monthlyCalendar.ts b/src/features/home/user/schedule/types/monthlyCalendar.ts similarity index 88% rename from src/features/home/user/types/monthlyCalendar.ts rename to src/features/home/user/schedule/types/monthlyCalendar.ts index bed9761..f509867 100644 --- a/src/features/home/user/types/monthlyCalendar.ts +++ b/src/features/home/user/schedule/types/monthlyCalendar.ts @@ -1,5 +1,5 @@ -import type { WEEKDAY_LABELS_MONDAY_FIRST } from '@/features/home/user/constants/calendar' -import type { BaseCalendarProps } from '@/features/home/user/types/calendar' +import type { WEEKDAY_LABELS_MONDAY_FIRST } from '@/features/home/user/schedule/constants/calendar' +import type { BaseCalendarProps } from '@/features/home/user/schedule/types/calendar' export interface MonthlyCellInput { dateKey: string diff --git a/src/features/home/user/types/schedule.ts b/src/features/home/user/schedule/types/schedule.ts similarity index 100% rename from src/features/home/user/types/schedule.ts rename to src/features/home/user/schedule/types/schedule.ts diff --git a/src/features/home/user/types/scheduleList.ts b/src/features/home/user/schedule/types/scheduleList.ts similarity index 100% rename from src/features/home/user/types/scheduleList.ts rename to src/features/home/user/schedule/types/scheduleList.ts diff --git a/src/features/home/user/types/weeklyCalendar.ts b/src/features/home/user/schedule/types/weeklyCalendar.ts similarity index 82% rename from src/features/home/user/types/weeklyCalendar.ts rename to src/features/home/user/schedule/types/weeklyCalendar.ts index fd97d7f..55e9a64 100644 --- a/src/features/home/user/types/weeklyCalendar.ts +++ b/src/features/home/user/schedule/types/weeklyCalendar.ts @@ -1,4 +1,4 @@ -import type { BaseCalendarProps } from '@/features/home/user/types/calendar' +import type { BaseCalendarProps } from '@/features/home/user/schedule/types/calendar' export type WeeklyCalendarPropsBase = BaseCalendarProps diff --git a/src/features/home/user/ui/DailyCalendar.tsx b/src/features/home/user/schedule/ui/DailyCalendar.tsx similarity index 97% rename from src/features/home/user/ui/DailyCalendar.tsx rename to src/features/home/user/schedule/ui/DailyCalendar.tsx index e0188e1..3197ce6 100644 --- a/src/features/home/user/ui/DailyCalendar.tsx +++ b/src/features/home/user/schedule/ui/DailyCalendar.tsx @@ -1,6 +1,6 @@ import DownIcon from '@/assets/icons/home/chevron-down.svg?react' -import { useDailyCalendarViewModel } from '@/features/home/user/hooks/useDailyCalendarViewModel' -import type { DailyCalendarPropsBase } from '@/features/home/user/types/dailyCalendar' +import { useDailyCalendarViewModel } from '@/features/home/user/schedule/hooks/useDailyCalendarViewModel' +import type { DailyCalendarPropsBase } from '@/features/home/user/schedule/types/dailyCalendar' interface DailyCalendarProps extends DailyCalendarPropsBase { isLoading?: boolean diff --git a/src/features/home/user/ui/HomeScheduleCalendar.tsx b/src/features/home/user/schedule/ui/HomeScheduleCalendar.tsx similarity index 78% rename from src/features/home/user/ui/HomeScheduleCalendar.tsx rename to src/features/home/user/schedule/ui/HomeScheduleCalendar.tsx index 491aa40..dd2836c 100644 --- a/src/features/home/user/ui/HomeScheduleCalendar.tsx +++ b/src/features/home/user/schedule/ui/HomeScheduleCalendar.tsx @@ -1,10 +1,10 @@ import type { CalendarViewData, HomeCalendarMode, -} from '@/features/home/user/types/schedule' -import { DailyCalendar } from '@/features/home/user/ui/DailyCalendar' -import { MonthlyCalendar } from '@/features/home/user/ui/MonthlyCalendar' -import { WeeklyCalendar } from '@/features/home/user/ui/WeeklyCalendar' +} from '@/features/home/user/schedule/types/schedule' +import { DailyCalendar } from '@/features/home/user/schedule/ui/DailyCalendar' +import { MonthlyCalendar } from '@/features/home/user/schedule/ui/MonthlyCalendar' +import { WeeklyCalendar } from '@/features/home/user/schedule/ui/WeeklyCalendar' interface HomeScheduleCalendarProps { mode: HomeCalendarMode diff --git a/src/features/home/user/ui/MonthlyCalendar.tsx b/src/features/home/user/schedule/ui/MonthlyCalendar.tsx similarity index 93% rename from src/features/home/user/ui/MonthlyCalendar.tsx rename to src/features/home/user/schedule/ui/MonthlyCalendar.tsx index 225a0ec..17c6d3d 100644 --- a/src/features/home/user/ui/MonthlyCalendar.tsx +++ b/src/features/home/user/schedule/ui/MonthlyCalendar.tsx @@ -1,7 +1,7 @@ import DownIcon from '@/assets/icons/home/chevron-down.svg?react' -import { useMonthlyCalendarViewModel } from '@/features/home/user/hooks/useMonthlyCalendarViewModel' -import type { MonthlyCalendarPropsBase } from '@/features/home/user/types/monthlyCalendar' -import { MonthlyDateCell } from '@/features/home/user/ui/MonthlyDateCell' +import { useMonthlyCalendarViewModel } from '@/features/home/user/schedule/hooks/useMonthlyCalendarViewModel' +import type { MonthlyCalendarPropsBase } from '@/features/home/user/schedule/types/monthlyCalendar' +import { MonthlyDateCell } from '@/features/home/user/schedule/ui/MonthlyDateCell' interface MonthlyCalendarProps extends MonthlyCalendarPropsBase { isLoading?: boolean diff --git a/src/features/home/user/ui/MonthlyDateCell.tsx b/src/features/home/user/schedule/ui/MonthlyDateCell.tsx similarity index 92% rename from src/features/home/user/ui/MonthlyDateCell.tsx rename to src/features/home/user/schedule/ui/MonthlyDateCell.tsx index d2f038b..046c98b 100644 --- a/src/features/home/user/ui/MonthlyDateCell.tsx +++ b/src/features/home/user/schedule/ui/MonthlyDateCell.tsx @@ -1,4 +1,4 @@ -import { MonthlyDateGauge } from '@/features/home/user/ui/MonthlyDateGauge' +import { MonthlyDateGauge } from '@/features/home/user/schedule/ui/MonthlyDateGauge' interface MonthlyDateCellProps { dayText: string diff --git a/src/features/home/user/ui/MonthlyDateGauge.tsx b/src/features/home/user/schedule/ui/MonthlyDateGauge.tsx similarity index 100% rename from src/features/home/user/ui/MonthlyDateGauge.tsx rename to src/features/home/user/schedule/ui/MonthlyDateGauge.tsx diff --git a/src/features/home/user/ui/WeeklyCalendar.tsx b/src/features/home/user/schedule/ui/WeeklyCalendar.tsx similarity index 97% rename from src/features/home/user/ui/WeeklyCalendar.tsx rename to src/features/home/user/schedule/ui/WeeklyCalendar.tsx index 93df0b8..2ed2077 100644 --- a/src/features/home/user/ui/WeeklyCalendar.tsx +++ b/src/features/home/user/schedule/ui/WeeklyCalendar.tsx @@ -1,5 +1,5 @@ -import { useWeeklyCalendarViewModel } from '@/features/home/user/hooks/useWeeklyCalendarViewModel' -import type { WeeklyCalendarPropsBase } from '@/features/home/user/types/weeklyCalendar' +import { useWeeklyCalendarViewModel } from '@/features/home/user/schedule/hooks/useWeeklyCalendarViewModel' +import type { WeeklyCalendarPropsBase } from '@/features/home/user/schedule/types/weeklyCalendar' interface WeeklyCalendarProps extends WeeklyCalendarPropsBase { isLoading?: boolean diff --git a/src/features/home/user/api/workspace.ts b/src/features/home/user/workspace/api/workspace.ts similarity index 94% rename from src/features/home/user/api/workspace.ts rename to src/features/home/user/workspace/api/workspace.ts index 51f744a..a44de10 100644 --- a/src/features/home/user/api/workspace.ts +++ b/src/features/home/user/workspace/api/workspace.ts @@ -3,7 +3,7 @@ import type { WorkspaceItem, WorkspaceListApiResponse, WorkspaceListQueryParams, -} from '@/features/home/user/types/workspace' +} from '@/features/home/user/workspace/types/workspace' function mapToWorkspaceItem(dto: WorkspaceListApiResponse['data']['data'][number]): WorkspaceItem { return { diff --git a/src/features/home/user/hooks/useWorkingStoreCardViewModel.ts b/src/features/home/user/workspace/hooks/useWorkingStoreCardViewModel.ts similarity index 91% rename from src/features/home/user/hooks/useWorkingStoreCardViewModel.ts rename to src/features/home/user/workspace/hooks/useWorkingStoreCardViewModel.ts index 1022534..ca91c2f 100644 --- a/src/features/home/user/hooks/useWorkingStoreCardViewModel.ts +++ b/src/features/home/user/workspace/hooks/useWorkingStoreCardViewModel.ts @@ -1,6 +1,6 @@ import { differenceInCalendarDays, format, parseISO } from 'date-fns' import { useMemo } from 'react' -import type { WorkingStoreItem } from '@/features/home/user/ui/WorkingStoreCard' +import type { WorkingStoreItem } from '@/features/home/user/workspace/ui/WorkingStoreCard' function formatNextShiftDate(nextShiftDateTime: string) { const date = parseISO(nextShiftDateTime) diff --git a/src/features/home/user/hooks/useWorkingStoresListViewModel.ts b/src/features/home/user/workspace/hooks/useWorkingStoresListViewModel.ts similarity index 69% rename from src/features/home/user/hooks/useWorkingStoresListViewModel.ts rename to src/features/home/user/workspace/hooks/useWorkingStoresListViewModel.ts index 4d95ea3..9f3f521 100644 --- a/src/features/home/user/hooks/useWorkingStoresListViewModel.ts +++ b/src/features/home/user/workspace/hooks/useWorkingStoresListViewModel.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import type { WorkingStoreItem } from '@/features/home/user/ui/WorkingStoreCard' +import type { WorkingStoreItem } from '@/features/home/user/workspace/ui/WorkingStoreCard' export function useWorkingStoresListViewModel(stores: WorkingStoreItem[]) { return useMemo( diff --git a/src/features/home/user/hooks/useWorkspacesViewModel.ts b/src/features/home/user/workspace/hooks/useWorkspacesViewModel.ts similarity index 96% rename from src/features/home/user/hooks/useWorkspacesViewModel.ts rename to src/features/home/user/workspace/hooks/useWorkspacesViewModel.ts index 4e67566..e92cf48 100644 --- a/src/features/home/user/hooks/useWorkspacesViewModel.ts +++ b/src/features/home/user/workspace/hooks/useWorkspacesViewModel.ts @@ -1,5 +1,5 @@ import { useInfiniteQuery } from '@tanstack/react-query' -import { adaptWorkspaceListResponse, getMyWorkspaces } from '@/features/home/user/api/workspace' +import { adaptWorkspaceListResponse, getMyWorkspaces } from '@/features/home/user/workspace/api/workspace' import { queryKeys } from '@/shared/lib/queryKeys' const PAGE_SIZE = 10 diff --git a/src/features/home/user/types/workspace.ts b/src/features/home/user/workspace/types/workspace.ts similarity index 100% rename from src/features/home/user/types/workspace.ts rename to src/features/home/user/workspace/types/workspace.ts diff --git a/src/features/home/user/ui/WorkingStoreCard.tsx b/src/features/home/user/workspace/ui/WorkingStoreCard.tsx similarity index 96% rename from src/features/home/user/ui/WorkingStoreCard.tsx rename to src/features/home/user/workspace/ui/WorkingStoreCard.tsx index 72e225b..6b1b2a3 100644 --- a/src/features/home/user/ui/WorkingStoreCard.tsx +++ b/src/features/home/user/workspace/ui/WorkingStoreCard.tsx @@ -1,4 +1,4 @@ -import { useWorkingStoreCardViewModel } from '@/features/home/user/hooks/useWorkingStoreCardViewModel' +import { useWorkingStoreCardViewModel } from '@/features/home/user/workspace/hooks/useWorkingStoreCardViewModel' export interface WorkingStoreItem { workspaceId: number diff --git a/src/features/home/user/ui/WorkingStoresList.tsx b/src/features/home/user/workspace/ui/WorkingStoresList.tsx similarity index 94% rename from src/features/home/user/ui/WorkingStoresList.tsx rename to src/features/home/user/workspace/ui/WorkingStoresList.tsx index e08afbc..9894b96 100644 --- a/src/features/home/user/ui/WorkingStoresList.tsx +++ b/src/features/home/user/workspace/ui/WorkingStoresList.tsx @@ -1,9 +1,9 @@ import { MoreButton } from '@/shared/ui/common/MoreButton' -import { useWorkingStoresListViewModel } from '@/features/home/user/hooks/useWorkingStoresListViewModel' +import { useWorkingStoresListViewModel } from '@/features/home/user/workspace/hooks/useWorkingStoresListViewModel' import { WorkingStoreCard, type WorkingStoreItem, -} from '@/features/home/user/ui/WorkingStoreCard' +} from '@/features/home/user/workspace/ui/WorkingStoreCard' interface WorkingStoresListProps { title?: string diff --git a/src/pages/user/applied-stores/index.tsx b/src/pages/user/applied-stores/index.tsx index eb52e08..45e2c90 100644 --- a/src/pages/user/applied-stores/index.tsx +++ b/src/pages/user/applied-stores/index.tsx @@ -1,12 +1,12 @@ import { useState } from 'react' import { Navbar } from '@/shared/ui/common/Navbar' -import { AppliedStoreListItem } from '@/features/home/user/ui/AppliedStoreListItem' -import { AppliedStoreDetailModal } from '@/features/home/user/ui/AppliedStoreDetailModal' -import { useAppliedStoresViewModel } from '@/features/home/user/hooks/useAppliedStoresViewModel' +import { AppliedStoreListItem } from '@/features/home/user/applied-stores/ui/AppliedStoreListItem' +import { AppliedStoreDetailModal } from '@/features/home/user/applied-stores/ui/AppliedStoreDetailModal' +import { useAppliedStoresViewModel } from '@/features/home/user/applied-stores/hooks/useAppliedStoresViewModel' import type { AppliedApplicationDetail, AppliedStoreData, -} from '@/features/home/user/types/appliedStore' +} from '@/features/home/user/applied-stores/types/appliedStore' import DownIcon from '@/assets/icons/home/chevron-down.svg?react' const SAMPLE_APPLICATION_DETAIL: AppliedApplicationDetail = { diff --git a/src/pages/user/home/index.tsx b/src/pages/user/home/index.tsx index 5ad896c..c4923e1 100644 --- a/src/pages/user/home/index.tsx +++ b/src/pages/user/home/index.tsx @@ -3,9 +3,9 @@ import { WorkingStoresList, AppliedStoreList, } from '@/features/home' -import type { WorkingStoreItem } from '@/features/home/user/ui/WorkingStoreCard' -import type { AppliedStoreItem } from '@/features/home/user/ui/AppliedStoreList' -import { useHomeScheduleViewModel } from '@/features/home/user/hooks/useHomeScheduleViewModel' +import type { WorkingStoreItem } from '@/features/home/user/workspace/ui/WorkingStoreCard' +import type { AppliedStoreItem } from '@/features/home/user/applied-stores/ui/AppliedStoreList' +import { useHomeScheduleViewModel } from '@/features/home/user/schedule/hooks/useHomeScheduleViewModel' import { Navbar } from '@/shared/ui/common/Navbar' import { useNavigate } from 'react-router-dom' diff --git a/src/pages/user/schedule/components/ScheduleItem.tsx b/src/pages/user/schedule/components/ScheduleItem.tsx index 8fb336c..5161273 100644 --- a/src/pages/user/schedule/components/ScheduleItem.tsx +++ b/src/pages/user/schedule/components/ScheduleItem.tsx @@ -1,4 +1,4 @@ -import type { ScheduleListItem } from '@/features/home/user/types/scheduleList' +import type { ScheduleListItem } from '@/features/home/user/schedule/types/scheduleList' interface ScheduleItemProps extends ScheduleListItem { onClick?: (id: string) => void diff --git a/src/pages/user/schedule/index.tsx b/src/pages/user/schedule/index.tsx index 7c6358f..4079577 100644 --- a/src/pages/user/schedule/index.tsx +++ b/src/pages/user/schedule/index.tsx @@ -1,5 +1,5 @@ -import type { ScheduleListItem } from '@/features/home/user/types/scheduleList' -import { useScheduleListViewModel } from '@/features/home/user/hooks/useScheduleListViewModel' +import type { ScheduleListItem } from '@/features/home/user/schedule/types/scheduleList' +import { useScheduleListViewModel } from '@/features/home/user/schedule/hooks/useScheduleListViewModel' import { ScheduleItem } from './components/ScheduleItem' import { ChevronLeftIcon } from '@/assets/icons/ChevronLeftIcon' import { ChevronRightIcon } from '@/assets/icons/ChevronRightIcon' diff --git a/src/pages/user/workspace/index.tsx b/src/pages/user/workspace/index.tsx index c52e5a0..3162df4 100644 --- a/src/pages/user/workspace/index.tsx +++ b/src/pages/user/workspace/index.tsx @@ -1,7 +1,7 @@ import { useNavigate } from 'react-router-dom' import { Navbar } from '@/shared/ui/common/Navbar' -import { WorkingStoreCard } from '@/features/home/user/ui/WorkingStoreCard' -import { useWorkspacesViewModel } from '@/features/home/user/hooks/useWorkspacesViewModel' +import { WorkingStoreCard } from '@/features/home/user/workspace/ui/WorkingStoreCard' +import { useWorkspacesViewModel } from '@/features/home/user/workspace/hooks/useWorkspacesViewModel' export function WorkspacePage() { const navigate = useNavigate() diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index e1ffb07..95c7d3c 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -1,4 +1,4 @@ -import type { SelfScheduleQueryParams } from '@/features/home/user/api/schedule' +import type { SelfScheduleQueryParams } from '@/features/home/user/schedule/api/schedule' export const queryKeys = { schedules: { From 292ccb527920a8b8442dbfb424e63e860c1c3138 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Thu, 16 Apr 2026 01:26:18 +0900 Subject: [PATCH 12/39] =?UTF-8?q?feat:=20=EC=97=85=EC=9E=A5=EB=B3=84=20?= =?UTF-8?q?=EA=B7=BC=EB=AC=B4=20=EC=8A=A4=EC=BC=80=EC=A4=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/user/workspace/api/workspace.ts | 4 +- .../user/workspace/api/workspaceSchedule.ts | 105 +++++++++++++++++ .../hooks/useWorkspaceScheduleViewModel.ts | 52 +++++++++ .../workspace/hooks/useWorkspacesViewModel.ts | 46 +++++--- .../user/workspace/types/workspaceSchedule.ts | 48 ++++++++ src/pages/user/workspace-detail/index.tsx | 108 ++++++++---------- src/pages/user/workspace/index.tsx | 16 ++- src/shared/lib/queryKeys.ts | 4 + 8 files changed, 301 insertions(+), 82 deletions(-) create mode 100644 src/features/home/user/workspace/api/workspaceSchedule.ts create mode 100644 src/features/home/user/workspace/hooks/useWorkspaceScheduleViewModel.ts create mode 100644 src/features/home/user/workspace/types/workspaceSchedule.ts diff --git a/src/features/home/user/workspace/api/workspace.ts b/src/features/home/user/workspace/api/workspace.ts index a44de10..7e208b4 100644 --- a/src/features/home/user/workspace/api/workspace.ts +++ b/src/features/home/user/workspace/api/workspace.ts @@ -5,7 +5,9 @@ import type { WorkspaceListQueryParams, } from '@/features/home/user/workspace/types/workspace' -function mapToWorkspaceItem(dto: WorkspaceListApiResponse['data']['data'][number]): WorkspaceItem { +function mapToWorkspaceItem( + dto: WorkspaceListApiResponse['data']['data'][number] +): WorkspaceItem { return { workspaceId: dto.workspaceId, businessName: dto.businessName, diff --git a/src/features/home/user/workspace/api/workspaceSchedule.ts b/src/features/home/user/workspace/api/workspaceSchedule.ts new file mode 100644 index 0000000..251e217 --- /dev/null +++ b/src/features/home/user/workspace/api/workspaceSchedule.ts @@ -0,0 +1,105 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + CalendarEvent, + CalendarViewData, +} from '@/features/home/user/schedule/types/schedule' +import { + toDateKey, + toTimeLabel, + getDurationHours, + formatScheduleTimeRange, +} from '@/features/home/user/schedule/lib/date' +import type { + WorkspaceScheduleApiResponse, + WorkspaceScheduleQueryParams, + WorkspaceShiftItem, + WorkspaceWorkerItem, +} from '@/features/home/user/workspace/types/workspaceSchedule' + +export async function getWorkspaceSchedule( + workspaceId: number, + params?: WorkspaceScheduleQueryParams +): Promise { + const response = await axiosInstance.get( + `/app/schedules/workspaces/${workspaceId}`, + { params } + ) + return response.data +} + +export function adaptWorkspaceScheduleToCalendar( + response: WorkspaceScheduleApiResponse +): CalendarViewData { + const shifts = response.data + + const events: CalendarEvent[] = shifts.map(shift => ({ + shiftId: shift.shiftId, + workspaceName: shift.assignedWorker.workerName, + position: shift.position, + status: shift.status, + startDateTime: shift.startDateTime, + endDateTime: shift.endDateTime, + dateKey: toDateKey(shift.startDateTime), + startTimeLabel: toTimeLabel(shift.startDateTime), + endTimeLabel: toTimeLabel(shift.endDateTime), + durationHours: getDurationHours(shift.startDateTime, shift.endDateTime), + })) + + const totalWorkHours = events.reduce((acc, e) => acc + e.durationHours, 0) + + return { + summary: { totalWorkHours, eventCount: events.length }, + events, + } +} + +export function adaptWorkspaceScheduleToShifts( + response: WorkspaceScheduleApiResponse +): WorkspaceShiftItem[] { + return response.data.map(shift => { + const { time } = formatScheduleTimeRange( + shift.startDateTime, + shift.endDateTime + ) + return { + shiftId: shift.shiftId, + workerId: shift.assignedWorker.workerId, + workerName: shift.assignedWorker.workerName, + position: shift.position, + status: shift.status, + startDateTime: shift.startDateTime, + endDateTime: shift.endDateTime, + timeRange: time, + durationHours: getDurationHours(shift.startDateTime, shift.endDateTime), + } + }) +} + +export function deriveWorkerList( + response: WorkspaceScheduleApiResponse +): WorkspaceWorkerItem[] { + const workerMap = new Map() + + const sorted = [...response.data].sort( + (a, b) => + new Date(a.startDateTime).getTime() - new Date(b.startDateTime).getTime() + ) + + for (const shift of sorted) { + const { workerId, workerName } = shift.assignedWorker + if (!workerMap.has(workerId)) { + const { time } = formatScheduleTimeRange( + shift.startDateTime, + shift.endDateTime + ) + workerMap.set(workerId, { + workerId, + workerName, + nextShiftDateTime: shift.startDateTime, + nextShiftTimeRange: time, + }) + } + } + + return Array.from(workerMap.values()) +} diff --git a/src/features/home/user/workspace/hooks/useWorkspaceScheduleViewModel.ts b/src/features/home/user/workspace/hooks/useWorkspaceScheduleViewModel.ts new file mode 100644 index 0000000..1cbee67 --- /dev/null +++ b/src/features/home/user/workspace/hooks/useWorkspaceScheduleViewModel.ts @@ -0,0 +1,52 @@ +import { useCallback, useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import type { HomeCalendarMode } from '@/features/home/user/schedule/types/schedule' +import { getScheduleParamsByMode } from '@/features/home/user/schedule/lib/date' +import { queryKeys } from '@/shared/lib/queryKeys' +import { + getWorkspaceSchedule, + adaptWorkspaceScheduleToCalendar, + deriveWorkerList, +} from '@/features/home/user/workspace/api/workspaceSchedule' + +export function useWorkspaceScheduleViewModel(workspaceId: number) { + const [mode, setMode] = useState('monthly') + const [baseDate, setBaseDate] = useState(() => new Date()) + + const params = getScheduleParamsByMode(baseDate, mode) + + const { + data: rawData, + isPending, + isError, + } = useQuery({ + queryKey: queryKeys.workspace.schedules(workspaceId, params), + queryFn: () => getWorkspaceSchedule(workspaceId, params), + enabled: workspaceId > 0, + }) + + const calendarData = useMemo( + () => (rawData ? adaptWorkspaceScheduleToCalendar(rawData) : null), + [rawData] + ) + + const workers = useMemo( + () => (rawData ? deriveWorkerList(rawData) : []), + [rawData] + ) + + const onDateChange = useCallback((nextDate: Date) => { + setBaseDate(nextDate) + }, []) + + return { + mode, + setMode, + baseDate, + calendarData, + workers, + isLoading: isPending, + isError, + onDateChange, + } +} diff --git a/src/features/home/user/workspace/hooks/useWorkspacesViewModel.ts b/src/features/home/user/workspace/hooks/useWorkspacesViewModel.ts index e92cf48..d845255 100644 --- a/src/features/home/user/workspace/hooks/useWorkspacesViewModel.ts +++ b/src/features/home/user/workspace/hooks/useWorkspacesViewModel.ts @@ -1,28 +1,38 @@ import { useInfiniteQuery } from '@tanstack/react-query' -import { adaptWorkspaceListResponse, getMyWorkspaces } from '@/features/home/user/workspace/api/workspace' +import { + adaptWorkspaceListResponse, + getMyWorkspaces, +} from '@/features/home/user/workspace/api/workspace' import { queryKeys } from '@/shared/lib/queryKeys' const PAGE_SIZE = 10 export function useWorkspacesViewModel() { - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } = - useInfiniteQuery({ - queryKey: queryKeys.workspace.list({ pageSize: PAGE_SIZE }), - queryFn: async ({ pageParam }) => { - const response = await getMyWorkspaces({ - pageSize: PAGE_SIZE, - cursor: pageParam as string | undefined, - }) - return response - }, - initialPageParam: undefined as string | undefined, - getNextPageParam: lastPage => { - const cursor = lastPage.data.page.cursor - return cursor || undefined - }, - }) + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useInfiniteQuery({ + queryKey: queryKeys.workspace.list({ pageSize: PAGE_SIZE }), + queryFn: async ({ pageParam }) => { + const response = await getMyWorkspaces({ + pageSize: PAGE_SIZE, + cursor: pageParam as string | undefined, + }) + return response + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => { + const cursor = lastPage.data.page.cursor + return cursor || undefined + }, + }) - const workspaces = data?.pages.flatMap(page => adaptWorkspaceListResponse(page)) ?? [] + const workspaces = + data?.pages.flatMap(page => adaptWorkspaceListResponse(page)) ?? [] return { workspaces, diff --git a/src/features/home/user/workspace/types/workspaceSchedule.ts b/src/features/home/user/workspace/types/workspaceSchedule.ts new file mode 100644 index 0000000..df3c180 --- /dev/null +++ b/src/features/home/user/workspace/types/workspaceSchedule.ts @@ -0,0 +1,48 @@ +import type { CommonApiResponse } from '@/shared/types/common' +import type { StatusEnum } from '@/shared/types/enums' + +// ---- DTO ---- +export interface WorkspaceShiftWorkerDto { + workerId: number + workerName: string +} + +export interface WorkspaceShiftDto { + shiftId: number + assignedWorker: WorkspaceShiftWorkerDto + startDateTime: string + endDateTime: string + position: string + status: StatusEnum +} + +export type WorkspaceScheduleApiResponse = CommonApiResponse< + WorkspaceShiftDto[] +> + +// ---- Query Params ---- +export interface WorkspaceScheduleQueryParams { + year?: number + month?: number + day?: number +} + +// ---- UI Model ---- +export interface WorkspaceShiftItem { + shiftId: number + workerId: number + workerName: string + position: string + status: StatusEnum + startDateTime: string + endDateTime: string + timeRange: string + durationHours: number +} + +export interface WorkspaceWorkerItem { + workerId: number + workerName: string + nextShiftDateTime: string + nextShiftTimeRange: string +} diff --git a/src/pages/user/workspace-detail/index.tsx b/src/pages/user/workspace-detail/index.tsx index f8b2788..75370ea 100644 --- a/src/pages/user/workspace-detail/index.tsx +++ b/src/pages/user/workspace-detail/index.tsx @@ -1,85 +1,73 @@ +import { useParams, useLocation } from 'react-router-dom' import { Navbar } from '@/shared/ui/common/Navbar' import { HomeScheduleCalendar } from '@/features/home' import { StoreWorkerListItem } from '@/features/home/manager/ui/StoreWorkerListItem' -import type { StoreWorkerRole } from '@/features/home/manager/ui/StoreWorkerListItem' -import CrownIcon from '@/assets/icons/home/crown-solid.svg' +import { useWorkspaceScheduleViewModel } from '@/features/home/user/workspace/hooks/useWorkspaceScheduleViewModel' import UsersIcon from '@/assets/icons/home/users.svg' +import { format, parseISO } from 'date-fns' -interface WorkspaceMember { - id: string - name: string - role: StoreWorkerRole - nextWorkDate: string - profileImageUrl?: string +function formatNextShift(isoDate: string) { + const date = parseISO(isoDate) + if (Number.isNaN(date.getTime())) return '-' + return format(date, 'yyyy. M. d.') } -const DUMMY_STORE_NAME = '집게리아' - -const DUMMY_MANAGERS: WorkspaceMember[] = [ - { id: '1', name: '이름임', role: 'owner', nextWorkDate: '2025. 1. 1.' }, -] +export function WorkspaceDetailPage() { + const { workspaceId } = useParams<{ workspaceId: string }>() + const { state } = useLocation() + const id = Number(workspaceId) + const businessName = (state as { businessName?: string } | null)?.businessName -const DUMMY_WORKERS: WorkspaceMember[] = [ - { id: '2', name: '이름임', role: 'manager', nextWorkDate: '2025. 1. 1.' }, - { id: '3', name: '이름임', role: 'staff', nextWorkDate: '2025. 1. 1.' }, -] + const { mode, baseDate, calendarData, workers, isLoading, onDateChange } = + useWorkspaceScheduleViewModel(id) -export function WorkspaceDetailPage() { return (
{}} + mode={mode} + baseDate={baseDate} + data={calendarData} + isLoading={isLoading} + workspaceName={businessName} + onDateChange={onDateChange} /> -
-
- - - 관리자 ({DUMMY_MANAGERS.length}명) - -
-
- {DUMMY_MANAGERS.map(member => ( -
- {}} - /> -
- ))} -
-
-
- 근무자 ({DUMMY_WORKERS.length}명) + 근무자 ({workers.length}명)
-
- {DUMMY_WORKERS.map(member => ( -
- {}} - /> -
- ))} -
+ + {isLoading ? ( +
+

+ 로딩 중... +

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

+ 이 기간에 예정된 근무자가 없습니다. +

+
+ ) : ( +
+ {workers.map(worker => ( +
+ {}} + /> +
+ ))} +
+ )}
diff --git a/src/pages/user/workspace/index.tsx b/src/pages/user/workspace/index.tsx index 3162df4..2fc820b 100644 --- a/src/pages/user/workspace/index.tsx +++ b/src/pages/user/workspace/index.tsx @@ -5,8 +5,14 @@ import { useWorkspacesViewModel } from '@/features/home/user/workspace/hooks/use export function WorkspacePage() { const navigate = useNavigate() - const { workspaces, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } = - useWorkspacesViewModel() + const { + workspaces, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useWorkspacesViewModel() if (isError) { return ( @@ -36,7 +42,11 @@ export function WorkspacePage() { key={store.workspaceId} type="button" className="rounded-2xl bg-white py-[11px] text-left" - onClick={() => navigate(`/user/workspace/${store.workspaceId}`)} + onClick={() => + navigate(`/user/workspace/${store.workspaceId}`, { + state: { businessName: store.businessName }, + }) + } > diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index 95c7d3c..ed83e99 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -13,5 +13,9 @@ export const queryKeys = { ['workspace', 'managers', workspaceId, cursor, pageSize] as const, list: (params?: { pageSize: number }) => ['workspace', 'list', params] as const, + schedules: ( + workspaceId: number, + params?: { year?: number; month?: number; day?: number } + ) => ['workspace', 'schedules', workspaceId, params] as const, }, } as const From e1de542d59ed6bea25d1fddc6c4c81d1b1e9f7b1 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Thu, 16 Apr 2026 01:42:58 +0900 Subject: [PATCH 13/39] =?UTF-8?q?feat:=20=EA=B7=BC=EB=AC=B4=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=EC=97=85=EC=9E=A5=20=EA=B7=BC=EB=AC=B4=EC=9E=90=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/workspace/api/workspaceMembers.ts | 66 ++++++ .../hooks/useWorkspaceManagersViewModel.ts | 35 +++ .../hooks/useWorkspaceWorkersViewModel.ts | 35 +++ .../user/workspace/types/workspaceMembers.ts | 65 ++++++ src/pages/user/workspace-detail/index.tsx | 68 ++++-- .../components/ManagersSection.tsx | 14 +- .../components/WorkersSection.tsx | 14 +- .../hooks/useWorkspaceMembers.ts | 206 ++---------------- src/shared/api/workspaceMembers.ts | 115 ---------- src/shared/hooks/index.ts | 4 +- src/shared/hooks/useWorkspaceManagersQuery.ts | 56 ----- src/shared/hooks/useWorkspaceWorkersQuery.ts | 54 ----- 12 files changed, 284 insertions(+), 448 deletions(-) create mode 100644 src/features/home/user/workspace/api/workspaceMembers.ts create mode 100644 src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts create mode 100644 src/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel.ts create mode 100644 src/features/home/user/workspace/types/workspaceMembers.ts delete mode 100644 src/shared/api/workspaceMembers.ts delete mode 100644 src/shared/hooks/useWorkspaceManagersQuery.ts delete mode 100644 src/shared/hooks/useWorkspaceWorkersQuery.ts diff --git a/src/features/home/user/workspace/api/workspaceMembers.ts b/src/features/home/user/workspace/api/workspaceMembers.ts new file mode 100644 index 0000000..76de4c1 --- /dev/null +++ b/src/features/home/user/workspace/api/workspaceMembers.ts @@ -0,0 +1,66 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + WorkspaceMembersQueryParams, + WorkspaceManagerItem, + WorkspaceManagersApiResponse, + WorkspaceWorkerItem, + WorkspaceWorkersApiResponse, + WorkspaceWorkerDto, + WorkspaceManagerDto, +} from '@/features/home/user/workspace/types/workspaceMembers' + +export async function getWorkspaceWorkers( + workspaceId: number, + params: WorkspaceMembersQueryParams +): Promise { + const response = await axiosInstance.get( + `/app/users/me/workspaces/${workspaceId}/workers`, + { + params: { + pageSize: params.pageSize, + ...(params.cursor !== undefined && { cursor: params.cursor }), + }, + } + ) + return response.data +} + +export async function getWorkspaceManagers( + workspaceId: number, + params: WorkspaceMembersQueryParams +): Promise { + const response = await axiosInstance.get( + `/app/users/me/workspaces/${workspaceId}/managers`, + { + params: { + pageSize: params.pageSize, + ...(params.cursor !== undefined && { cursor: params.cursor }), + }, + } + ) + return response.data +} + +export function adaptWorkerDto(dto: WorkspaceWorkerDto): WorkspaceWorkerItem { + return { + id: dto.id, + userId: dto.user.id, + name: dto.user.name, + positionType: dto.position.type, + positionDescription: dto.position.description, + positionEmoji: dto.position.emoji, + employedAt: dto.employedAt, + nextShiftDateTime: dto.nextShiftDateTime, + } +} + +export function adaptManagerDto(dto: WorkspaceManagerDto): WorkspaceManagerItem { + return { + id: dto.id, + managerId: dto.manager.id, + name: dto.manager.name, + positionType: dto.position.type, + positionDescription: dto.position.description, + positionEmoji: dto.position.emoji, + } +} diff --git a/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts b/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts new file mode 100644 index 0000000..ef765a6 --- /dev/null +++ b/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts @@ -0,0 +1,35 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { + getWorkspaceManagers, + adaptManagerDto, +} from '@/features/home/user/workspace/api/workspaceMembers' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useWorkspaceManagersViewModel( + workspaceId: number, + pageSize = 10 +) { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isError } = + useInfiniteQuery({ + queryKey: queryKeys.workspace.managers(workspaceId), + queryFn: ({ pageParam }) => + getWorkspaceManagers(workspaceId, { + pageSize, + cursor: pageParam as string | undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, + enabled: workspaceId > 0, + }) + + const managers = data?.pages.flatMap(page => page.data.data.map(adaptManagerDto)) ?? [] + + return { + managers, + fetchNextPage, + hasNextPage: !!hasNextPage, + isFetchingNextPage, + isLoading: isPending, + isError, + } +} diff --git a/src/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel.ts b/src/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel.ts new file mode 100644 index 0000000..7a468fb --- /dev/null +++ b/src/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel.ts @@ -0,0 +1,35 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { + getWorkspaceWorkers, + adaptWorkerDto, +} from '@/features/home/user/workspace/api/workspaceMembers' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useWorkspaceWorkersViewModel( + workspaceId: number, + pageSize = 10 +) { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isError } = + useInfiniteQuery({ + queryKey: queryKeys.workspace.workers(workspaceId), + queryFn: ({ pageParam }) => + getWorkspaceWorkers(workspaceId, { + pageSize, + cursor: pageParam as string | undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, + enabled: workspaceId > 0, + }) + + const workers = data?.pages.flatMap(page => page.data.data.map(adaptWorkerDto)) ?? [] + + return { + workers, + fetchNextPage, + hasNextPage: !!hasNextPage, + isFetchingNextPage, + isLoading: isPending, + isError, + } +} diff --git a/src/features/home/user/workspace/types/workspaceMembers.ts b/src/features/home/user/workspace/types/workspaceMembers.ts new file mode 100644 index 0000000..ec5f04c --- /dev/null +++ b/src/features/home/user/workspace/types/workspaceMembers.ts @@ -0,0 +1,65 @@ +import type { CommonApiResponse } from '@/shared/types/common' + +// ---- DTO ---- +export interface WorkspacePositionDto { + type: string + description: string + emoji: string +} + +export interface WorkspaceWorkerDto { + id: number + user: { id: number; name: string } + position: WorkspacePositionDto + employedAt: string + nextShiftDateTime: string +} + +export interface WorkspaceManagerDto { + id: number + manager: { id: number; name: string } + position: WorkspacePositionDto +} + +export interface WorkspaceMembersPageDto { + cursor: string | null + pageSize: number + totalCount: number +} + +export type WorkspaceWorkersApiResponse = CommonApiResponse<{ + page: WorkspaceMembersPageDto + data: WorkspaceWorkerDto[] +}> + +export type WorkspaceManagersApiResponse = CommonApiResponse<{ + page: WorkspaceMembersPageDto + data: WorkspaceManagerDto[] +}> + +// ---- Params ---- +export interface WorkspaceMembersQueryParams { + cursor?: string + pageSize: number +} + +// ---- UI Model ---- +export interface WorkspaceWorkerItem { + id: number + userId: number + name: string + positionType: string + positionDescription: string + positionEmoji: string + employedAt: string + nextShiftDateTime: string +} + +export interface WorkspaceManagerItem { + id: number + managerId: number + name: string + positionType: string + positionDescription: string + positionEmoji: string +} diff --git a/src/pages/user/workspace-detail/index.tsx b/src/pages/user/workspace-detail/index.tsx index 75370ea..bf28cae 100644 --- a/src/pages/user/workspace-detail/index.tsx +++ b/src/pages/user/workspace-detail/index.tsx @@ -1,8 +1,8 @@ import { useParams, useLocation } from 'react-router-dom' import { Navbar } from '@/shared/ui/common/Navbar' import { HomeScheduleCalendar } from '@/features/home' -import { StoreWorkerListItem } from '@/features/home/manager/ui/StoreWorkerListItem' import { useWorkspaceScheduleViewModel } from '@/features/home/user/workspace/hooks/useWorkspaceScheduleViewModel' +import { useWorkspaceWorkersViewModel } from '@/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel' import UsersIcon from '@/assets/icons/home/users.svg' import { format, parseISO } from 'date-fns' @@ -18,9 +18,17 @@ export function WorkspaceDetailPage() { const id = Number(workspaceId) const businessName = (state as { businessName?: string } | null)?.businessName - const { mode, baseDate, calendarData, workers, isLoading, onDateChange } = + const { mode, baseDate, calendarData, isLoading: scheduleLoading, onDateChange } = useWorkspaceScheduleViewModel(id) + const { + workers, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading: workersLoading, + } = useWorkspaceWorkersViewModel(id, 5) + return (
@@ -29,7 +37,7 @@ export function WorkspaceDetailPage() { mode={mode} baseDate={baseDate} data={calendarData} - isLoading={isLoading} + isLoading={scheduleLoading} workspaceName={businessName} onDateChange={onDateChange} /> @@ -42,31 +50,51 @@ export function WorkspaceDetailPage() {
- {isLoading ? ( + {workersLoading ? (
-

- 로딩 중... -

+

로딩 중...

) : workers.length === 0 ? (

- 이 기간에 예정된 근무자가 없습니다. + 등록된 근무자가 없습니다.

) : ( -
- {workers.map(worker => ( -
- {}} - /> -
- ))} -
+ <> +
+ {workers.map(worker => ( +
+
+

+ {worker.name} +

+

+ {worker.positionEmoji}{' '} + {worker.positionDescription || worker.positionType} +

+
+

+ {formatNextShift(worker.nextShiftDateTime)} +

+
+ ))} +
+ + {hasNextPage && ( + + )} + )}
diff --git a/src/pages/user/workspace-members/components/ManagersSection.tsx b/src/pages/user/workspace-members/components/ManagersSection.tsx index 3de3f1e..df6fb5f 100644 --- a/src/pages/user/workspace-members/components/ManagersSection.tsx +++ b/src/pages/user/workspace-members/components/ManagersSection.tsx @@ -1,15 +1,13 @@ -import type { WorkspaceManagerDto } from '@/shared/api/workspaceMembers' +import type { WorkspaceManagerItem } from '@/features/home/user/workspace/types/workspaceMembers' import { LoadMoreButton } from './LoadMoreButton' type Props = { - managers: WorkspaceManagerDto[] + managers: WorkspaceManagerItem[] hasMore: boolean onLoadMore: () => void } -export function ManagersSection(props: Props) { - const { managers, hasMore, onLoadMore } = props - +export function ManagersSection({ managers, hasMore, onLoadMore }: Props) { if (managers.length === 0) return null return ( @@ -25,11 +23,11 @@ export function ManagersSection(props: Props) { >
- {manager.manager.name} + {manager.name} - {manager.position.emoji}{' '} - {manager.position.description || manager.position.type} + {manager.positionEmoji}{' '} + {manager.positionDescription || manager.positionType}
diff --git a/src/pages/user/workspace-members/components/WorkersSection.tsx b/src/pages/user/workspace-members/components/WorkersSection.tsx index 99c4462..5b615cd 100644 --- a/src/pages/user/workspace-members/components/WorkersSection.tsx +++ b/src/pages/user/workspace-members/components/WorkersSection.tsx @@ -1,15 +1,13 @@ -import type { WorkspaceWorkerDto } from '@/shared/api/workspaceMembers' +import type { WorkspaceWorkerItem } from '@/features/home/user/workspace/types/workspaceMembers' import { LoadMoreButton } from './LoadMoreButton' type Props = { - workers: WorkspaceWorkerDto[] + workers: WorkspaceWorkerItem[] hasMore: boolean onLoadMore: () => void } -export function WorkersSection(props: Props) { - const { workers, hasMore, onLoadMore } = props - +export function WorkersSection({ workers, hasMore, onLoadMore }: Props) { if (workers.length === 0) return null return ( @@ -25,11 +23,11 @@ export function WorkersSection(props: Props) { >
- {worker.user.name} + {worker.name} - {worker.position.emoji}{' '} - {worker.position.description || worker.position.type} + {worker.positionEmoji}{' '} + {worker.positionDescription || worker.positionType}
diff --git a/src/pages/user/workspace-members/hooks/useWorkspaceMembers.ts b/src/pages/user/workspace-members/hooks/useWorkspaceMembers.ts index 67af613..bf067ae 100644 --- a/src/pages/user/workspace-members/hooks/useWorkspaceMembers.ts +++ b/src/pages/user/workspace-members/hooks/useWorkspaceMembers.ts @@ -1,211 +1,47 @@ -import { useEffect, useMemo, useState } from 'react' -import { useWorkspaceManagersQuery } from '@/shared/hooks/useWorkspaceManagersQuery' -import { useWorkspaceWorkersQuery } from '@/shared/hooks/useWorkspaceWorkersQuery' +import { useMemo } from 'react' +import { useWorkspaceWorkersViewModel } from '@/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel' +import { useWorkspaceManagersViewModel } from '@/features/home/user/workspace/hooks/useWorkspaceManagersViewModel' type Params = { workspaceId?: string initialPageSize?: number - loadMorePageSize?: number } export function useWorkspaceMembers(params: Params) { - const { workspaceId, initialPageSize = 3, loadMorePageSize = 10 } = params + const { workspaceId, initialPageSize = 3 } = params - const numericWorkspaceId = useMemo(() => { + const numericId = useMemo(() => { const parsed = Number(workspaceId) - return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0 }, [workspaceId]) - const isWorkspaceIdInvalid = !numericWorkspaceId - type WorkersState = { - workspaceId?: number - cursor?: string - items: ReturnType['workers'] - } - - type ManagersState = { - workspaceId?: number - cursor?: string - items: ReturnType['managers'] - } - - const [workersState, setWorkersState] = useState({ - workspaceId: numericWorkspaceId, - cursor: undefined, - items: [], - }) - - const [managersState, setManagersState] = useState({ - workspaceId: numericWorkspaceId, - cursor: undefined, - items: [], - }) - - const workersCursor = - workersState.workspaceId === numericWorkspaceId - ? workersState.cursor - : undefined - const managersCursor = - managersState.workspaceId === numericWorkspaceId - ? managersState.cursor - : undefined + const isWorkspaceIdInvalid = numericId === 0 const { workers, + fetchNextPage: fetchNextWorkers, + hasNextPage: hasMoreWorkers, isLoading: workersLoading, - error: workersError, - page: workersPage, - } = useWorkspaceWorkersQuery({ - workspaceId: numericWorkspaceId, - cursor: workersCursor, - pageSize: workersCursor ? loadMorePageSize : initialPageSize, - }) + isError: workersError, + } = useWorkspaceWorkersViewModel(numericId, initialPageSize) const { managers, + fetchNextPage: fetchNextManagers, + hasNextPage: hasMoreManagers, isLoading: managersLoading, - error: managersError, - page: managersPage, - } = useWorkspaceManagersQuery({ - workspaceId: numericWorkspaceId, - cursor: managersCursor, - pageSize: managersCursor ? loadMorePageSize : initialPageSize, - }) - const allWorkers = - workersState.workspaceId === numericWorkspaceId ? workersState.items : [] - const allManagers = - managersState.workspaceId === numericWorkspaceId ? managersState.items : [] - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setWorkersState(prev => { - if (prev.workspaceId === numericWorkspaceId) return prev - - return { - workspaceId: numericWorkspaceId, - cursor: undefined, - items: [], - } - }) - - setManagersState(prev => { - if (prev.workspaceId === numericWorkspaceId) return prev - - return { - workspaceId: numericWorkspaceId, - cursor: undefined, - items: [], - } - }) - }, [numericWorkspaceId]) - - useEffect(() => { - if (!numericWorkspaceId || workersLoading || workersError) return - if (!workers || workers.length === 0) return - - // eslint-disable-next-line react-hooks/set-state-in-effect - setWorkersState(prev => { - if (prev.workspaceId !== numericWorkspaceId) { - const existingIds = new Set(workers.map(worker => worker.id)) - const uniqueWorkers = workers.filter( - worker => !existingIds.has(worker.id) - ) - - return { - workspaceId: numericWorkspaceId, - cursor: undefined, - items: uniqueWorkers, - } - } - - const existingIds = new Set(prev.items.map(worker => worker.id)) - const appended = workers.filter(worker => !existingIds.has(worker.id)) - - return { - ...prev, - items: [...prev.items, ...appended], - } - }) - }, [numericWorkspaceId, workers, workersLoading, workersError]) - - useEffect(() => { - if (!numericWorkspaceId || managersLoading || managersError) return - if (!managers || managers.length === 0) return - - // eslint-disable-next-line react-hooks/set-state-in-effect - setManagersState(prev => { - if (prev.workspaceId !== numericWorkspaceId) { - const existingIds = new Set(managers.map(manager => manager.id)) - const uniqueManagers = managers.filter( - manager => !existingIds.has(manager.id) - ) - - return { - workspaceId: numericWorkspaceId, - cursor: undefined, - items: uniqueManagers, - } - } - - const existingIds = new Set(prev.items.map(manager => manager.id)) - const appended = managers.filter(manager => !existingIds.has(manager.id)) - - return { - ...prev, - items: [...prev.items, ...appended], - } - }) - }, [numericWorkspaceId, managers, managersLoading, managersError]) - - const workersTotalCount = workersPage?.totalCount ?? allWorkers.length - const managersTotalCount = managersPage?.totalCount ?? allManagers.length - - const hasMoreWorkers = workersTotalCount > allWorkers.length - const hasMoreManagers = managersTotalCount > allManagers.length - - const loadMoreWorkers = () => { - if (!numericWorkspaceId) return - - setWorkersState(prev => { - if (prev.workspaceId !== numericWorkspaceId) return prev - - return { - ...prev, - cursor: workersPage?.cursor ?? undefined, - } - }) - } - - const loadMoreManagers = () => { - if (!numericWorkspaceId) return - - setManagersState(prev => { - if (prev.workspaceId !== numericWorkspaceId) return prev - - return { - ...prev, - cursor: managersPage?.cursor ?? undefined, - } - }) - } - - const isLoading = workersLoading || managersLoading - const hasError = !!workersError || !!managersError + isError: managersError, + } = useWorkspaceManagersViewModel(numericId, initialPageSize) return { - numericWorkspaceId, isWorkspaceIdInvalid, - - isLoading, - hasError, - - workers: allWorkers, - managers: allManagers, - + isLoading: workersLoading || managersLoading, + hasError: workersError || managersError, + workers, + managers, hasMoreWorkers, hasMoreManagers, - - loadMoreWorkers, - loadMoreManagers, + loadMoreWorkers: fetchNextWorkers, + loadMoreManagers: fetchNextManagers, } } diff --git a/src/shared/api/workspaceMembers.ts b/src/shared/api/workspaceMembers.ts deleted file mode 100644 index da49b73..0000000 --- a/src/shared/api/workspaceMembers.ts +++ /dev/null @@ -1,115 +0,0 @@ -import axios from 'axios' -import axiosInstance from '@/shared/lib/axiosInstance' -import type { - ApiError, - CommonApiResponse, - ErrorResponse, -} from '@/shared/types/common' - -type PageInfo = { - cursor: string | null - pageSize: number - totalCount: number -} - -type PositionDto = { - type: string - description: string - emoji: string -} - -export type WorkspaceWorkerDto = { - id: number - user: { - id: number - name: string - } - position: PositionDto - employedAt: string - nextShiftDateTime: string -} - -export type WorkspaceManagerDto = { - id: number - manager: { - id: number - name: string - } - position: PositionDto -} - -export type WorkspaceWorkersResponse = CommonApiResponse<{ - page: PageInfo - data: WorkspaceWorkerDto[] -}> - -export type WorkspaceManagersResponse = CommonApiResponse<{ - page: PageInfo - data: WorkspaceManagerDto[] -}> - -export type WorkspaceMembersParams = { - workspaceId: number - cursor?: string - pageSize?: number -} - -export async function getWorkspaceWorkers( - params: WorkspaceMembersParams -): Promise { - const { workspaceId, cursor, pageSize } = params - - try { - const response = await axiosInstance.get( - `/app/users/me/workspaces/${workspaceId}/workers`, - { - params: { - ...(cursor !== undefined && { cursor }), - ...(pageSize !== undefined && { pageSize }), - }, - } - ) - return response.data - } catch (error) { - if (axios.isAxiosError(error)) { - const errorData: ErrorResponse = error.response?.data ?? {} - const message = - errorData.message ?? '근무자 목록 조회 중 오류가 발생했습니다.' - const apiError = new Error(message) as ApiError & Error - apiError.data = errorData - throw apiError - } - - throw new Error('근무자 목록 조회 중 오류가 발생했습니다.') - } -} - -export async function getWorkspaceManagers( - params: WorkspaceMembersParams -): Promise { - const { workspaceId, cursor, pageSize } = params - - try { - const response = await axiosInstance.get( - `/app/users/me/workspaces/${workspaceId}/managers`, - { - params: { - ...(cursor !== undefined && { cursor }), - ...(pageSize !== undefined && { pageSize }), - }, - } - ) - return response.data - } catch (error) { - if (axios.isAxiosError(error)) { - const errorData: ErrorResponse = error.response?.data ?? {} - const message = - errorData.message ?? '점주/매니저 목록 조회 중 오류가 발생했습니다.' - const apiError = new Error(message) as ApiError & Error - apiError.data = errorData - throw apiError - } - - throw new Error('점주/매니저 목록 조회 중 오류가 발생했습니다.') - } -} diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 16c38cb..67ef6b3 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1,2 +1,2 @@ -export { useWorkspaceWorkersQuery } from './useWorkspaceWorkersQuery' -export { useWorkspaceManagersQuery } from './useWorkspaceManagersQuery' +export { useWorkspaceWorkersViewModel as useWorkspaceWorkersQuery } from '@/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel' +export { useWorkspaceManagersViewModel as useWorkspaceManagersQuery } from '@/features/home/user/workspace/hooks/useWorkspaceManagersViewModel' diff --git a/src/shared/hooks/useWorkspaceManagersQuery.ts b/src/shared/hooks/useWorkspaceManagersQuery.ts deleted file mode 100644 index 510b066..0000000 --- a/src/shared/hooks/useWorkspaceManagersQuery.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { useMemo } from 'react' -import { useQuery } from '@tanstack/react-query' -import { - getWorkspaceManagers, - type WorkspaceManagersResponse, -} from '@/shared/api/workspaceMembers' -import { queryKeys } from '@/shared/lib/queryKeys' - -type WorkspaceManagersQueryParams = { - workspaceId?: number - cursor?: string - pageSize?: number -} - -type WorkspaceManagersData = WorkspaceManagersResponse['data'] - -export function useWorkspaceManagersQuery( - params: WorkspaceManagersQueryParams -) { - const { workspaceId, cursor, pageSize } = params - - const { data, isPending, error } = useQuery({ - queryKey: queryKeys.workspace.managers(workspaceId, cursor, pageSize), - queryFn: async () => { - if (!workspaceId) { - throw new Error('workspaceId는 필수입니다.') - } - - const res = await getWorkspaceManagers({ - workspaceId, - cursor, - pageSize, - }) - return res.data - }, - enabled: !!workspaceId, - }) - - const managers = useMemo( - () => (data as WorkspaceManagersData | undefined)?.data ?? [], - [data] - ) - - const pageInfo = useMemo( - () => (data as WorkspaceManagersData | undefined)?.page, - [data] - ) - - return { - managers, - page: pageInfo, - isLoading: isPending, - error, - rawData: data as WorkspaceManagersData | undefined, - } -} diff --git a/src/shared/hooks/useWorkspaceWorkersQuery.ts b/src/shared/hooks/useWorkspaceWorkersQuery.ts deleted file mode 100644 index 181e835..0000000 --- a/src/shared/hooks/useWorkspaceWorkersQuery.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useMemo } from 'react' -import { useQuery } from '@tanstack/react-query' -import { - getWorkspaceWorkers, - type WorkspaceWorkersResponse, -} from '@/shared/api/workspaceMembers' -import { queryKeys } from '@/shared/lib/queryKeys' - -type WorkspaceWorkersQueryParams = { - workspaceId?: number - cursor?: string - pageSize?: number -} - -type WorkspaceWorkersData = WorkspaceWorkersResponse['data'] - -export function useWorkspaceWorkersQuery(params: WorkspaceWorkersQueryParams) { - const { workspaceId, cursor, pageSize } = params - - const { data, isPending, error } = useQuery({ - queryKey: queryKeys.workspace.workers(workspaceId, cursor, pageSize), - queryFn: async () => { - if (!workspaceId) { - throw new Error('workspaceId는 필수입니다.') - } - - const res = await getWorkspaceWorkers({ - workspaceId, - cursor, - pageSize, - }) - return res.data - }, - enabled: !!workspaceId, - }) - - const workers = useMemo( - () => (data as WorkspaceWorkersData | undefined)?.data ?? [], - [data] - ) - - const pageInfo = useMemo( - () => (data as WorkspaceWorkersData | undefined)?.page, - [data] - ) - - return { - workers, - page: pageInfo, - isLoading: isPending, - error, - rawData: data as WorkspaceWorkersData | undefined, - } -} From 826da249aee9a4a1bf2fbffcba019f02e54d6ab1 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Thu, 16 Apr 2026 01:45:13 +0900 Subject: [PATCH 14/39] =?UTF-8?q?fix:=20storybook=20import=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- storybook/stories/AppliedStoreCard.stories.tsx | 2 +- storybook/stories/AppliedStoreList.stories.tsx | 2 +- storybook/stories/HomeScheduleCalendar.stories.tsx | 4 ++-- storybook/stories/MonthlyDateCell.stories.tsx | 2 +- storybook/stories/WorkingStoresCard.stories.tsx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/storybook/stories/AppliedStoreCard.stories.tsx b/storybook/stories/AppliedStoreCard.stories.tsx index 595b2d0..b755e25 100644 --- a/storybook/stories/AppliedStoreCard.stories.tsx +++ b/storybook/stories/AppliedStoreCard.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import React from 'react' -import { AppliedStoreCard } from '../../src/features/home/user/ui/AppliedStoreCard' +import { AppliedStoreCard } from '../../src/features/home/user/applied-stores/ui/AppliedStoreCard' const meta = { title: 'features/home/user/AppliedStoreCard', diff --git a/storybook/stories/AppliedStoreList.stories.tsx b/storybook/stories/AppliedStoreList.stories.tsx index ffb2de2..6490515 100644 --- a/storybook/stories/AppliedStoreList.stories.tsx +++ b/storybook/stories/AppliedStoreList.stories.tsx @@ -3,7 +3,7 @@ import React from 'react' import { AppliedStoreList, type AppliedStoreItem, -} from '../../src/features/home/user/ui/AppliedStoreList' +} from '../../src/features/home/user/applied-stores/ui/AppliedStoreList' const sampleStores: AppliedStoreItem[] = [ { id: 1, storeName: '지원한 매장 이름입니다.', status: 'applied' }, diff --git a/storybook/stories/HomeScheduleCalendar.stories.tsx b/storybook/stories/HomeScheduleCalendar.stories.tsx index c2d5912..dc90ca8 100644 --- a/storybook/stories/HomeScheduleCalendar.stories.tsx +++ b/storybook/stories/HomeScheduleCalendar.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import React, { useMemo, useState } from 'react' -import { HomeScheduleCalendar } from '../../src/features/home/user/ui/HomeScheduleCalendar' +import { HomeScheduleCalendar } from '../../src/features/home/user/schedule/ui/HomeScheduleCalendar' import type { CalendarViewData, HomeCalendarMode, -} from '../../src/features/home/user/types/schedule' +} from '../../src/features/home/user/schedule/types/schedule' const baseDate = new Date('2026-01-19T09:00:00+09:00') diff --git a/storybook/stories/MonthlyDateCell.stories.tsx b/storybook/stories/MonthlyDateCell.stories.tsx index 5783290..7b7fb39 100644 --- a/storybook/stories/MonthlyDateCell.stories.tsx +++ b/storybook/stories/MonthlyDateCell.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import React from 'react' -import { MonthlyDateCell } from '../../src/features/home/user/ui/MonthlyDateCell' +import { MonthlyDateCell } from '../../src/features/home/user/schedule/ui/MonthlyDateCell' const meta = { title: 'features/home/user/MonthlyDateCell', diff --git a/storybook/stories/WorkingStoresCard.stories.tsx b/storybook/stories/WorkingStoresCard.stories.tsx index 2a1dcdd..c7e5825 100644 --- a/storybook/stories/WorkingStoresCard.stories.tsx +++ b/storybook/stories/WorkingStoresCard.stories.tsx @@ -3,7 +3,7 @@ import React from 'react' import { WorkingStoresList, type WorkingStoreItem, -} from '../../src/features/home/user/ui/WorkingStoresList' +} from '../../src/features/home/user/workspace/ui/WorkingStoresList' const sampleStores: WorkingStoreItem[] = [ { From bc33002e9b9c2188043367ec5134bca918c64744 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Thu, 16 Apr 2026 02:02:22 +0900 Subject: [PATCH 15/39] =?UTF-8?q?feat:=20=EC=97=85=EC=9E=A5=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/workspace/api/workspaceMembers.ts | 4 +- .../hooks/useWorkspaceManagersViewModel.ts | 33 +++-- .../hooks/useWorkspaceWorkersViewModel.ts | 33 +++-- src/pages/user/workspace-detail/index.tsx | 119 +++++++++++++----- src/shared/ui/home/WorkerListItem.tsx | 91 ++++++++++++++ src/shared/ui/manager/WorkerListItem.tsx | 81 ------------ storybook/stories/WorkerListItem.stories.tsx | 12 +- 7 files changed, 229 insertions(+), 144 deletions(-) create mode 100644 src/shared/ui/home/WorkerListItem.tsx delete mode 100644 src/shared/ui/manager/WorkerListItem.tsx diff --git a/src/features/home/user/workspace/api/workspaceMembers.ts b/src/features/home/user/workspace/api/workspaceMembers.ts index 76de4c1..57a10d4 100644 --- a/src/features/home/user/workspace/api/workspaceMembers.ts +++ b/src/features/home/user/workspace/api/workspaceMembers.ts @@ -54,7 +54,9 @@ export function adaptWorkerDto(dto: WorkspaceWorkerDto): WorkspaceWorkerItem { } } -export function adaptManagerDto(dto: WorkspaceManagerDto): WorkspaceManagerItem { +export function adaptManagerDto( + dto: WorkspaceManagerDto +): WorkspaceManagerItem { return { id: dto.id, managerId: dto.manager.id, diff --git a/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts b/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts index ef765a6..d771b4d 100644 --- a/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts +++ b/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts @@ -9,20 +9,27 @@ export function useWorkspaceManagersViewModel( workspaceId: number, pageSize = 10 ) { - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isError } = - useInfiniteQuery({ - queryKey: queryKeys.workspace.managers(workspaceId), - queryFn: ({ pageParam }) => - getWorkspaceManagers(workspaceId, { - pageSize, - cursor: pageParam as string | undefined, - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, - enabled: workspaceId > 0, - }) + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isError, + } = useInfiniteQuery({ + queryKey: queryKeys.workspace.managers(workspaceId), + queryFn: ({ pageParam }) => + getWorkspaceManagers(workspaceId, { + pageSize, + cursor: pageParam as string | undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, + enabled: workspaceId > 0, + }) - const managers = data?.pages.flatMap(page => page.data.data.map(adaptManagerDto)) ?? [] + const managers = + data?.pages.flatMap(page => page.data.data.map(adaptManagerDto)) ?? [] return { managers, diff --git a/src/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel.ts b/src/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel.ts index 7a468fb..601a694 100644 --- a/src/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel.ts +++ b/src/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel.ts @@ -9,20 +9,27 @@ export function useWorkspaceWorkersViewModel( workspaceId: number, pageSize = 10 ) { - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isError } = - useInfiniteQuery({ - queryKey: queryKeys.workspace.workers(workspaceId), - queryFn: ({ pageParam }) => - getWorkspaceWorkers(workspaceId, { - pageSize, - cursor: pageParam as string | undefined, - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, - enabled: workspaceId > 0, - }) + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isError, + } = useInfiniteQuery({ + queryKey: queryKeys.workspace.workers(workspaceId), + queryFn: ({ pageParam }) => + getWorkspaceWorkers(workspaceId, { + pageSize, + cursor: pageParam as string | undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, + enabled: workspaceId > 0, + }) - const workers = data?.pages.flatMap(page => page.data.data.map(adaptWorkerDto)) ?? [] + const workers = + data?.pages.flatMap(page => page.data.data.map(adaptWorkerDto)) ?? [] return { workers, diff --git a/src/pages/user/workspace-detail/index.tsx b/src/pages/user/workspace-detail/index.tsx index bf28cae..35cbe59 100644 --- a/src/pages/user/workspace-detail/index.tsx +++ b/src/pages/user/workspace-detail/index.tsx @@ -1,14 +1,17 @@ import { useParams, useLocation } from 'react-router-dom' +import { format, parseISO } from 'date-fns' import { Navbar } from '@/shared/ui/common/Navbar' import { HomeScheduleCalendar } from '@/features/home' +import { WorkerListItem } from '@/shared/ui/home/WorkerListItem' import { useWorkspaceScheduleViewModel } from '@/features/home/user/workspace/hooks/useWorkspaceScheduleViewModel' import { useWorkspaceWorkersViewModel } from '@/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel' +import { useWorkspaceManagersViewModel } from '@/features/home/user/workspace/hooks/useWorkspaceManagersViewModel' +import CrownIcon from '@/assets/icons/home/crown-solid.svg' import UsersIcon from '@/assets/icons/home/users.svg' -import { format, parseISO } from 'date-fns' function formatNextShift(isoDate: string) { const date = parseISO(isoDate) - if (Number.isNaN(date.getTime())) return '-' + if (Number.isNaN(date.getTime())) return undefined return format(date, 'yyyy. M. d.') } @@ -18,14 +21,27 @@ export function WorkspaceDetailPage() { const id = Number(workspaceId) const businessName = (state as { businessName?: string } | null)?.businessName - const { mode, baseDate, calendarData, isLoading: scheduleLoading, onDateChange } = - useWorkspaceScheduleViewModel(id) + const { + mode, + baseDate, + calendarData, + isLoading: scheduleLoading, + onDateChange, + } = useWorkspaceScheduleViewModel(id) + + const { + managers, + fetchNextPage: fetchNextManagers, + hasNextPage: hasMoreManagers, + isFetchingNextPage: fetchingManagers, + isLoading: managersLoading, + } = useWorkspaceManagersViewModel(id, 5) const { workers, - fetchNextPage, - hasNextPage, - isFetchingNextPage, + fetchNextPage: fetchNextWorkers, + hasNextPage: hasMoreWorkers, + isFetchingNextPage: fetchingWorkers, isLoading: workersLoading, } = useWorkspaceWorkersViewModel(id, 5) @@ -42,6 +58,55 @@ export function WorkspaceDetailPage() { onDateChange={onDateChange} /> + {/* 관리자 섹션 */} +
+
+ + + 관리자 ({managers.length}명) + +
+ + {managersLoading ? ( +
+

+ 로딩 중... +

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

+ 등록된 관리자가 없습니다. +

+
+ ) : ( + <> +
+ {managers.map(manager => ( + {}} + /> + ))} +
+ {hasMoreManagers && ( + + )} + + )} +
+ + {/* 근무자 섹션 */}
@@ -51,11 +116,13 @@ export function WorkspaceDetailPage() {
{workersLoading ? ( -
-

로딩 중...

+
+

+ 로딩 중... +

) : workers.length === 0 ? ( -
+

등록된 근무자가 없습니다.

@@ -64,34 +131,24 @@ export function WorkspaceDetailPage() { <>
{workers.map(worker => ( -
-
-

- {worker.name} -

-

- {worker.positionEmoji}{' '} - {worker.positionDescription || worker.positionType} -

-
-

- {formatNextShift(worker.nextShiftDateTime)} -

-
+ name={worker.name} + role={worker.positionDescription || worker.positionType} + variant="worker" + nextWorkDate={formatNextShift(worker.nextShiftDateTime)} + onOptions={() => {}} + /> ))}
- - {hasNextPage && ( + {hasMoreWorkers && ( )} diff --git a/src/shared/ui/home/WorkerListItem.tsx b/src/shared/ui/home/WorkerListItem.tsx new file mode 100644 index 0000000..136c541 --- /dev/null +++ b/src/shared/ui/home/WorkerListItem.tsx @@ -0,0 +1,91 @@ +export type WorkerVariant = 'manager' | 'worker' + +export interface WorkerListItemProps { + name: string + role: string + variant?: WorkerVariant + nextWorkDate?: string + imageUrl?: string | null + onOptions?: () => void +} + +export interface WorkerListItemData extends WorkerListItemProps { + id: string +} + +function EllipsisIcon() { + return ( + + + + + + ) +} + +export function WorkerListItem({ + name, + role, + variant = 'worker', + nextWorkDate, + imageUrl, + onOptions, +}: WorkerListItemProps) { + const badgeBg = variant === 'manager' ? 'bg-main-700' : 'bg-main-300' + + return ( +
+
+
+ {imageUrl ? ( + {name} + ) : null} +
+ +
+
+ + {name} + + + {role} + +
+ + {nextWorkDate && ( +
+ + 다음 근무 예정일 + + + {nextWorkDate} + +
+ )} +
+
+ + +
+ ) +} diff --git a/src/shared/ui/manager/WorkerListItem.tsx b/src/shared/ui/manager/WorkerListItem.tsx deleted file mode 100644 index fe83b4a..0000000 --- a/src/shared/ui/manager/WorkerListItem.tsx +++ /dev/null @@ -1,81 +0,0 @@ -// 근무자 목록 - -export type WorkerRole = '매니저' | '알바' - -export interface WorkerListItemProps { - name: string - role: WorkerRole - nextWorkDate: string - imageUrl?: string | null - onOptions?: () => void -} - -export interface WorkerListItemData extends WorkerListItemProps { - id: string -} - -function EllipsisIcon() { - return ( - - - - - - ) -} - -export function WorkerListItem({ - name, - role, - nextWorkDate, - imageUrl, - onOptions, -}: WorkerListItemProps) { - return ( -
-
- {imageUrl ? ( - {name} - ) : ( -
- )} -
-
-
- - {name} - - - {role} - -
-

- 다음 근무 예정일 {nextWorkDate} -

-
- -
- ) -} diff --git a/storybook/stories/WorkerListItem.stories.tsx b/storybook/stories/WorkerListItem.stories.tsx index 1385e1b..b38bb38 100644 --- a/storybook/stories/WorkerListItem.stories.tsx +++ b/storybook/stories/WorkerListItem.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import React from 'react' -import { WorkerListItem } from '../../src/shared/ui/manager/WorkerListItem' +import { WorkerListItem } from '../../src/shared/ui/home/WorkerListItem' const meta = { - title: 'shared/ui/manager/WorkerListItem', + title: 'shared/ui/home/WorkerListItem', component: WorkerListItem, parameters: { layout: 'centered' }, tags: ['autodocs'], @@ -20,19 +20,21 @@ const meta = { export default meta type Story = StoryObj -export const Manager: Story = { +export const ManagerVariant: Story = { args: { name: '이름임', - role: '매니저', + role: '사장님', + variant: 'manager', nextWorkDate: '2025. 1. 1.', onOptions: () => {}, }, } -export const PartTimer: Story = { +export const WorkerVariant: Story = { args: { name: '이름임', role: '알바', + variant: 'worker', nextWorkDate: '2025. 1. 1.', onOptions: () => {}, }, From 88ff969a5858849948e05bf11dd00cdd1f9ca17c Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Thu, 16 Apr 2026 02:31:39 +0900 Subject: [PATCH 16/39] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B3=B5=EA=B3=A0=20=EC=A7=80=EC=9B=90=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/applied-stores/api/application.ts | 21 +++ .../hooks/useAppliedStoresViewModel.ts | 69 ++++++-- .../user/applied-stores/types/application.ts | 150 +++++++++++++++++ src/pages/user/applied-stores/index.tsx | 155 +++++++----------- src/pages/user/home/index.tsx | 49 +++--- src/shared/lib/queryKeys.ts | 4 + 6 files changed, 313 insertions(+), 135 deletions(-) create mode 100644 src/features/home/user/applied-stores/api/application.ts create mode 100644 src/features/home/user/applied-stores/types/application.ts diff --git a/src/features/home/user/applied-stores/api/application.ts b/src/features/home/user/applied-stores/api/application.ts new file mode 100644 index 0000000..4f1f37f --- /dev/null +++ b/src/features/home/user/applied-stores/api/application.ts @@ -0,0 +1,21 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + ApplicationListApiResponse, + ApplicationListQueryParams, +} from '@/features/home/user/applied-stores/types/application' + +export async function getJobApplications( + params: ApplicationListQueryParams +): Promise { + const response = await axiosInstance.get( + '/app/users/me/postings/applications', + { + params: { + pageSize: params.pageSize, + ...(params.cursor !== undefined && { cursor: params.cursor }), + ...(params.status?.length && { status: params.status }), + }, + } + ) + return response.data +} diff --git a/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts b/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts index 0c6103a..0162a75 100644 --- a/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts +++ b/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts @@ -1,9 +1,18 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' import type { ApplicationStatus, - FilterType, AppliedStoreData, + FilterType, } from '@/features/home/user/applied-stores/types/appliedStore' +import { + FILTER_TO_API_STATUS, + adaptApplicationDto, +} from '@/features/home/user/applied-stores/types/application' +import { getJobApplications } from '@/features/home/user/applied-stores/api/application' +import { queryKeys } from '@/shared/lib/queryKeys' + +const PAGE_SIZE = 20 const FILTER_OPTIONS: { key: FilterType; label: string }[] = [ { key: 'completed', label: '지원 완료' }, @@ -27,11 +36,46 @@ function getFilterLabel(filter: FilterType): string { return FILTER_OPTIONS.find(o => o.key === filter)?.label ?? '전체' } -export function useAppliedStoresViewModel(stores: AppliedStoreData[]) { +export function useAppliedStoresViewModel() { const [selectedFilter, setSelectedFilter] = useState('all') const [isDropdownOpen, setIsDropdownOpen] = useState(false) const dropdownRef = useRef(null) + const apiStatus = FILTER_TO_API_STATUS[selectedFilter] + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isError } = + useInfiniteQuery({ + queryKey: queryKeys.application.list({ + status: apiStatus.length ? apiStatus : undefined, + pageSize: PAGE_SIZE, + }), + queryFn: ({ pageParam }) => + getJobApplications({ + pageSize: PAGE_SIZE, + cursor: pageParam as string | undefined, + status: apiStatus.length ? apiStatus : undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, + }) + + const stores = useMemo( + () => + data?.pages.flatMap(page => + page.data.data.map(adaptApplicationDto) + ) ?? [], + [data] + ) + + const grouped = useMemo( + () => + STATUS_SECTIONS.map(section => ({ + ...section, + stores: stores.filter(s => s.status === section.key), + })).filter(section => section.stores.length > 0), + [stores] + ) + useEffect(() => { function handleClickOutside(e: MouseEvent) { if ( @@ -47,18 +91,6 @@ export function useAppliedStoresViewModel(stores: AppliedStoreData[]) { return () => document.removeEventListener('mousedown', handleClickOutside) }, [isDropdownOpen]) - const filteredStores = - selectedFilter === 'all' - ? stores - : stores.filter(s => s.filterType === selectedFilter) - - const grouped = STATUS_SECTIONS.map(section => ({ - ...section, - stores: filteredStores.filter(s => s.status === section.key), - })).filter(section => section.stores.length > 0) - - const filterLabel = getFilterLabel(selectedFilter) - function toggleDropdown() { setIsDropdownOpen(prev => !prev) } @@ -69,7 +101,7 @@ export function useAppliedStoresViewModel(stores: AppliedStoreData[]) { } return { - filterLabel, + filterLabel: getFilterLabel(selectedFilter), isDropdownOpen, dropdownRef, filterOptions: FILTER_OPTIONS, @@ -77,5 +109,10 @@ export function useAppliedStoresViewModel(stores: AppliedStoreData[]) { toggleDropdown, selectFilter, getCardStatus, + fetchNextPage, + hasNextPage: !!hasNextPage, + isFetchingNextPage, + isLoading: isPending, + isError, } } diff --git a/src/features/home/user/applied-stores/types/application.ts b/src/features/home/user/applied-stores/types/application.ts new file mode 100644 index 0000000..4811936 --- /dev/null +++ b/src/features/home/user/applied-stores/types/application.ts @@ -0,0 +1,150 @@ +import type { CommonApiResponse } from '@/shared/types/common' +import type { + AppliedStoreData, + ApplicationStatus, + FilterType, + WeekdayLabel, +} from '@/features/home/user/applied-stores/types/appliedStore' + +// ---- API Status ---- +export type ApplicationApiStatus = + | 'SUBMITTED' + | 'SHORTLISTED' + | 'ACCEPTED' + | 'REJECTED' + | 'CANCELLED' + | 'EXPIRED' + | 'DELETED' + +// ---- DTO ---- +export interface PostingScheduleDto { + id: number + workingDays: string + startTime: string + endTime: string + position: string +} + +export interface PostingDto { + id: number + workspace: number + title: string + payAmount: number + paymentType: string +} + +export interface ApplicationDto { + id: number + postingSchedule: PostingScheduleDto + posting: PostingDto + description: string + status: { value: ApplicationApiStatus; description: string } + createdAt: string +} + +export interface ApplicationPageDto { + cursor: string | null + pageSize: number + totalCount: number +} + +export type ApplicationListApiResponse = CommonApiResponse<{ + page: ApplicationPageDto + data: ApplicationDto[] +}> + +// ---- Query Params ---- +export interface ApplicationListQueryParams { + cursor?: string + pageSize: number + status?: ApplicationApiStatus[] +} + +// ---- FilterType → API Status 매핑 ---- +export const FILTER_TO_API_STATUS: Record = + { + all: [], + not_viewed: ['SUBMITTED'], + viewed: ['SHORTLISTED', 'ACCEPTED', 'REJECTED'], + completed: ['ACCEPTED'], + cancelled: ['CANCELLED', 'EXPIRED', 'DELETED'], + } + +// ---- 근무 요일 파싱 ---- +const WORKING_DAY_MAP: Record = { + MONDAY: '월', + TUESDAY: '화', + WEDNESDAY: '수', + THURSDAY: '목', + FRIDAY: '금', + SATURDAY: '토', + SUNDAY: '일', +} + +export function parseWorkingDays(workingDaysStr: string): WeekdayLabel[] { + const matches = workingDaysStr.match(/[A-Z]+/g) ?? [] + return matches + .map(day => WORKING_DAY_MAP[day]) + .filter((d): d is WeekdayLabel => d !== undefined) +} + +// ---- 근무 시간 포맷 ---- +function calcDurationHours(startTime: string, endTime: string): number { + const [sh, sm] = startTime.split(':').map(Number) + const [eh, em] = endTime.split(':').map(Number) + const startMins = sh * 60 + sm + const endMins = eh * 60 + em + const diffMins = + endMins >= startMins ? endMins - startMins : 24 * 60 - startMins + endMins + const hours = diffMins / 60 + return Number.isInteger(hours) ? hours : Math.round(hours * 10) / 10 +} + +export function formatTimeRange(startTime: string, endTime: string): string { + const hours = calcDurationHours(startTime, endTime) + return `${startTime}~${endTime} (${hours}시간)` +} + +// ---- API Status → UI 타입 매핑 ---- +function mapApiStatusToUiStatus( + apiStatus: ApplicationApiStatus +): ApplicationStatus { + if (apiStatus === 'ACCEPTED') return 'accepted' + if ( + apiStatus === 'CANCELLED' || + apiStatus === 'REJECTED' || + apiStatus === 'EXPIRED' || + apiStatus === 'DELETED' + ) + return 'cancelled' + return 'submitted' +} + +function mapApiStatusToFilterType( + apiStatus: ApplicationApiStatus +): FilterType { + if (apiStatus === 'SUBMITTED') return 'not_viewed' + if (apiStatus === 'SHORTLISTED') return 'viewed' + if (apiStatus === 'ACCEPTED') return 'completed' + if (apiStatus === 'REJECTED') return 'viewed' + return 'cancelled' +} + +// ---- DTO → UI Model ---- +export function adaptApplicationDto(dto: ApplicationDto): AppliedStoreData { + const { postingSchedule, posting, description, status } = dto + return { + id: dto.id, + storeName: posting.title, + status: mapApiStatusToUiStatus(status.value), + filterType: mapApiStatusToFilterType(status.value), + applicationDetail: { + selectedWeekdays: parseWorkingDays(postingSchedule.workingDays), + timeRangeLabel: formatTimeRange( + postingSchedule.startTime, + postingSchedule.endTime + ), + selfIntroduction: description, + }, + } +} diff --git a/src/pages/user/applied-stores/index.tsx b/src/pages/user/applied-stores/index.tsx index 45e2c90..3fdbb2a 100644 --- a/src/pages/user/applied-stores/index.tsx +++ b/src/pages/user/applied-stores/index.tsx @@ -3,72 +3,11 @@ import { Navbar } from '@/shared/ui/common/Navbar' import { AppliedStoreListItem } from '@/features/home/user/applied-stores/ui/AppliedStoreListItem' import { AppliedStoreDetailModal } from '@/features/home/user/applied-stores/ui/AppliedStoreDetailModal' import { useAppliedStoresViewModel } from '@/features/home/user/applied-stores/hooks/useAppliedStoresViewModel' -import type { - AppliedApplicationDetail, - AppliedStoreData, -} from '@/features/home/user/applied-stores/types/appliedStore' +import type { AppliedStoreData } from '@/features/home/user/applied-stores/types/appliedStore' import DownIcon from '@/assets/icons/home/chevron-down.svg?react' -const SAMPLE_APPLICATION_DETAIL: AppliedApplicationDetail = { - selectedWeekdays: ['수', '금'], - timeRangeLabel: '18:00~20:00 (4시간)', - selfIntroduction: '안녕하세요. 돈주세요. 일 대충할건데 돈은 많이 주세요.', -} - -const INITIAL_STORES: AppliedStoreData[] = [ - { - id: 1, - storeName: '출근하기 싫은 가게 부천점', - status: 'submitted', - filterType: 'completed', - applicationDetail: SAMPLE_APPLICATION_DETAIL, - }, - { - id: 2, - storeName: '집에 가고 싶은 가게 부천점', - status: 'submitted', - filterType: 'completed', - applicationDetail: { - ...SAMPLE_APPLICATION_DETAIL, - selectedWeekdays: ['월', '화'], - timeRangeLabel: '10:00~14:00 (4시간)', - }, - }, - { - id: 3, - storeName: '출근하기 싫은 가게 고척점', - status: 'submitted', - filterType: 'not_viewed', - applicationDetail: { - ...SAMPLE_APPLICATION_DETAIL, - selectedWeekdays: ['토', '일'], - }, - }, - { - id: 4, - storeName: '출근하기 싫은 가게 고척점', - status: 'accepted', - filterType: 'viewed', - applicationDetail: { - ...SAMPLE_APPLICATION_DETAIL, - selectedWeekdays: ['목'], - selfIntroduction: '성실히 일하겠습니다.', - }, - }, - { - id: 5, - storeName: '집에 가고 싶은 가게 부천점', - status: 'cancelled', - filterType: 'cancelled', - applicationDetail: SAMPLE_APPLICATION_DETAIL, - }, -] - export function AppliedStoresPage() { - const [stores, setStores] = useState(INITIAL_STORES) - const [selectedStore, setSelectedStore] = useState( - null - ) + const [selectedStore, setSelectedStore] = useState(null) const { filterLabel, @@ -79,16 +18,15 @@ export function AppliedStoresPage() { toggleDropdown, selectFilter, getCardStatus, - } = useAppliedStoresViewModel(stores) + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useAppliedStoresViewModel() const closeDetail = () => setSelectedStore(null) - const handleCancelApplication = () => { - if (!selectedStore) return - setStores(prev => prev.filter(s => s.id !== selectedStore.id)) - closeDetail() - } - return (
@@ -110,9 +48,7 @@ export function AppliedStoresPage() { key={option.key} type="button" className={`flex h-10 w-full items-center px-4 typography-body02-regular text-text-100 ${ - index < filterOptions.length - 1 - ? 'border-b border-line-2' - : '' + index < filterOptions.length - 1 ? 'border-b border-line-2' : '' }`} onClick={() => selectFilter(option.key)} > @@ -123,26 +59,57 @@ export function AppliedStoresPage() { )}
-
- {grouped.map(section => ( -
-

- {section.label} -

-
- {section.stores.map(store => ( - setSelectedStore(store)} - /> - ))} -
-
- ))} -
+ {isLoading ? ( +
+

로딩 중...

+
+ ) : isError ? ( +
+

+ 데이터를 불러오는 데 실패했습니다. +

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

+ 지원 내역이 없습니다. +

+
+ ) : ( + <> +
+ {grouped.map(section => ( +
+

+ {section.label} +

+
+ {section.stores.map(store => ( + setSelectedStore(store)} + /> + ))} +
+
+ ))} +
+ + {hasNextPage && ( + + )} + + )}
{selectedStore?.applicationDetail && ( @@ -152,7 +119,7 @@ export function AppliedStoresPage() { storeName={selectedStore.storeName} detail={selectedStore.applicationDetail} showCancelButton={selectedStore.status === 'submitted'} - onCancel={handleCancelApplication} + onCancel={closeDetail} /> )}
diff --git a/src/pages/user/home/index.tsx b/src/pages/user/home/index.tsx index c4923e1..44a41a4 100644 --- a/src/pages/user/home/index.tsx +++ b/src/pages/user/home/index.tsx @@ -1,40 +1,39 @@ +import { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' import { HomeScheduleCalendar, WorkingStoresList, AppliedStoreList, } from '@/features/home' -import type { WorkingStoreItem } from '@/features/home/user/workspace/ui/WorkingStoreCard' import type { AppliedStoreItem } from '@/features/home/user/applied-stores/ui/AppliedStoreList' import { useHomeScheduleViewModel } from '@/features/home/user/schedule/hooks/useHomeScheduleViewModel' +import { useWorkspacesViewModel } from '@/features/home/user/workspace/hooks/useWorkspacesViewModel' +import { useAppliedStoresViewModel } from '@/features/home/user/applied-stores/hooks/useAppliedStoresViewModel' import { Navbar } from '@/shared/ui/common/Navbar' -import { useNavigate } from 'react-router-dom' - -const WORKING_STORES: WorkingStoreItem[] = [ - { - workspaceId: 1, - businessName: '스타벅스 강남점', - employedAt: '2024-01-01', - nextShiftDateTime: '2025-04-15T09:00:00', - }, - { - workspaceId: 2, - businessName: '맥도날드 홍대점', - employedAt: '2024-03-01', - nextShiftDateTime: '2025-04-18T14:00:00', - }, -] - -const APPLIED_STORES: AppliedStoreItem[] = [ - { id: 1, storeName: '이디야커피 신촌점', status: 'applied' }, - { id: 2, storeName: '베스킨라빈스 마포점', status: 'rejected' }, - { id: 3, storeName: '파리바게뜨 합정점', status: 'applied' }, -] export function UserHomePage() { const navigate = useNavigate() + const { mode, baseDate, calendarData, isLoading, onDateChange } = useHomeScheduleViewModel() + const { workspaces } = useWorkspacesViewModel() + + const { grouped } = useAppliedStoresViewModel() + + const appliedStores = useMemo( + () => + grouped + .flatMap(g => g.stores) + .slice(0, 5) + .map(s => ({ + id: s.id, + storeName: s.storeName, + status: s.status === 'cancelled' ? 'rejected' : 'applied', + })), + [grouped] + ) + return (
@@ -50,12 +49,12 @@ export function UserHomePage() { /> navigate('/user/workspace')} /> navigate('/user/applied-stores')} />
diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index ed83e99..47e549c 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -18,4 +18,8 @@ export const queryKeys = { params?: { year?: number; month?: number; day?: number } ) => ['workspace', 'schedules', workspaceId, params] as const, }, + application: { + list: (params?: { status?: string[]; pageSize?: number }) => + ['application', 'list', params] as const, + }, } as const From 0f8c3d1ee03e5726f84160dbef31e3acc49074dc Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Thu, 16 Apr 2026 14:45:02 +0900 Subject: [PATCH 17/39] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B3=A0=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=B7=A8=EC=86=8C=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/applied-stores/api/application.ts | 9 ++++ .../hooks/useAppliedStoresViewModel.ts | 43 ++++++++++++------- .../hooks/useCancelApplication.ts | 14 ++++++ .../user/applied-stores/types/application.ts | 4 +- .../ui/AppliedStoreDetailModal.tsx | 5 ++- src/pages/user/applied-stores/index.tsx | 18 ++++++-- 6 files changed, 70 insertions(+), 23 deletions(-) create mode 100644 src/features/home/user/applied-stores/hooks/useCancelApplication.ts diff --git a/src/features/home/user/applied-stores/api/application.ts b/src/features/home/user/applied-stores/api/application.ts index 4f1f37f..2fadeac 100644 --- a/src/features/home/user/applied-stores/api/application.ts +++ b/src/features/home/user/applied-stores/api/application.ts @@ -19,3 +19,12 @@ export async function getJobApplications( ) return response.data } + +export async function cancelJobApplication( + applicationId: number +): Promise { + await axiosInstance.patch( + `/app/users/me/postings/applications/${applicationId}/status`, + { status: 'CANCELLED' } + ) +} diff --git a/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts b/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts index 0162a75..e5ace31 100644 --- a/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts +++ b/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts @@ -10,6 +10,7 @@ import { adaptApplicationDto, } from '@/features/home/user/applied-stores/types/application' import { getJobApplications } from '@/features/home/user/applied-stores/api/application' +import { useCancelApplication } from '@/features/home/user/applied-stores/hooks/useCancelApplication' import { queryKeys } from '@/shared/lib/queryKeys' const PAGE_SIZE = 20 @@ -41,29 +42,37 @@ export function useAppliedStoresViewModel() { const [isDropdownOpen, setIsDropdownOpen] = useState(false) const dropdownRef = useRef(null) + const { mutate: cancelApplication, isPending: isCancelling } = + useCancelApplication() + const apiStatus = FILTER_TO_API_STATUS[selectedFilter] - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isError } = - useInfiniteQuery({ - queryKey: queryKeys.application.list({ - status: apiStatus.length ? apiStatus : undefined, + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isError, + } = useInfiniteQuery({ + queryKey: queryKeys.application.list({ + status: apiStatus.length ? apiStatus : undefined, + pageSize: PAGE_SIZE, + }), + queryFn: ({ pageParam }) => + getJobApplications({ pageSize: PAGE_SIZE, + cursor: pageParam as string | undefined, + status: apiStatus.length ? apiStatus : undefined, }), - queryFn: ({ pageParam }) => - getJobApplications({ - pageSize: PAGE_SIZE, - cursor: pageParam as string | undefined, - status: apiStatus.length ? apiStatus : undefined, - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, - }) + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, + }) const stores = useMemo( () => - data?.pages.flatMap(page => - page.data.data.map(adaptApplicationDto) - ) ?? [], + data?.pages.flatMap(page => page.data.data.map(adaptApplicationDto)) ?? + [], [data] ) @@ -114,5 +123,7 @@ export function useAppliedStoresViewModel() { isFetchingNextPage, isLoading: isPending, isError, + cancelApplication, + isCancelling, } } diff --git a/src/features/home/user/applied-stores/hooks/useCancelApplication.ts b/src/features/home/user/applied-stores/hooks/useCancelApplication.ts new file mode 100644 index 0000000..a89f92f --- /dev/null +++ b/src/features/home/user/applied-stores/hooks/useCancelApplication.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { cancelJobApplication } from '@/features/home/user/applied-stores/api/application' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useCancelApplication() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (applicationId: number) => cancelJobApplication(applicationId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.application.list() }) + }, + }) +} diff --git a/src/features/home/user/applied-stores/types/application.ts b/src/features/home/user/applied-stores/types/application.ts index 4811936..f226fe5 100644 --- a/src/features/home/user/applied-stores/types/application.ts +++ b/src/features/home/user/applied-stores/types/application.ts @@ -120,9 +120,7 @@ function mapApiStatusToUiStatus( return 'submitted' } -function mapApiStatusToFilterType( - apiStatus: ApplicationApiStatus -): FilterType { +function mapApiStatusToFilterType(apiStatus: ApplicationApiStatus): FilterType { if (apiStatus === 'SUBMITTED') return 'not_viewed' if (apiStatus === 'SHORTLISTED') return 'viewed' if (apiStatus === 'ACCEPTED') return 'completed' diff --git a/src/features/home/user/applied-stores/ui/AppliedStoreDetailModal.tsx b/src/features/home/user/applied-stores/ui/AppliedStoreDetailModal.tsx index 5b2ee50..ffe972f 100644 --- a/src/features/home/user/applied-stores/ui/AppliedStoreDetailModal.tsx +++ b/src/features/home/user/applied-stores/ui/AppliedStoreDetailModal.tsx @@ -11,6 +11,7 @@ interface AppliedStoreDetailModalProps { detail: AppliedApplicationDetail showCancelButton: boolean onCancel?: () => void + isCancelling?: boolean } export function AppliedStoreDetailModal({ @@ -20,6 +21,7 @@ export function AppliedStoreDetailModal({ detail, showCancelButton, onCancel, + isCancelling = false, }: AppliedStoreDetailModalProps) { useEffect(() => { if (!isOpen) return @@ -117,8 +119,9 @@ export function AppliedStoreDetailModal({
diff --git a/src/pages/user/applied-stores/index.tsx b/src/pages/user/applied-stores/index.tsx index 3fdbb2a..3e26607 100644 --- a/src/pages/user/applied-stores/index.tsx +++ b/src/pages/user/applied-stores/index.tsx @@ -7,7 +7,9 @@ import type { AppliedStoreData } from '@/features/home/user/applied-stores/types import DownIcon from '@/assets/icons/home/chevron-down.svg?react' export function AppliedStoresPage() { - const [selectedStore, setSelectedStore] = useState(null) + const [selectedStore, setSelectedStore] = useState( + null + ) const { filterLabel, @@ -23,10 +25,17 @@ export function AppliedStoresPage() { isFetchingNextPage, isLoading, isError, + cancelApplication, + isCancelling, } = useAppliedStoresViewModel() const closeDetail = () => setSelectedStore(null) + const handleCancel = () => { + if (!selectedStore) return + cancelApplication(selectedStore.id, { onSuccess: closeDetail }) + } + return (
@@ -48,7 +57,9 @@ export function AppliedStoresPage() { key={option.key} type="button" className={`flex h-10 w-full items-center px-4 typography-body02-regular text-text-100 ${ - index < filterOptions.length - 1 ? 'border-b border-line-2' : '' + index < filterOptions.length - 1 + ? 'border-b border-line-2' + : '' }`} onClick={() => selectFilter(option.key)} > @@ -119,7 +130,8 @@ export function AppliedStoresPage() { storeName={selectedStore.storeName} detail={selectedStore.applicationDetail} showCancelButton={selectedStore.status === 'submitted'} - onCancel={closeDetail} + onCancel={handleCancel} + isCancelling={isCancelling} /> )}
From e732adcfaab32349fc00b2aa6c2d87f07cd5ec08 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Thu, 16 Apr 2026 17:25:56 +0900 Subject: [PATCH 18/39] =?UTF-8?q?feat:=20=EC=82=AC=EC=9E=A5=EB=8B=98=20?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../icons/home/manager-home-banner-plus.svg | 12 + .../home/manager-workspace-modal-plus.svg | 12 + src/assets/manager-home-banner.jpg | Bin 0 -> 101952 bytes .../manager/hooks/useManagerHomeViewModel.ts | 197 +++++++++++++++ .../home/user/schedule/ui/MonthlyCalendar.tsx | 47 +++- src/pages/manager/home/index.tsx | 237 ++++++++++-------- src/shared/ui/manager/OngoingPostingCard.tsx | 87 +++++-- .../ui/manager/SubstituteApprovalCard.tsx | 75 +++--- 8 files changed, 494 insertions(+), 173 deletions(-) create mode 100644 src/assets/icons/home/manager-home-banner-plus.svg create mode 100644 src/assets/icons/home/manager-workspace-modal-plus.svg create mode 100644 src/assets/manager-home-banner.jpg create mode 100644 src/features/home/manager/hooks/useManagerHomeViewModel.ts diff --git a/src/assets/icons/home/manager-home-banner-plus.svg b/src/assets/icons/home/manager-home-banner-plus.svg new file mode 100644 index 0000000..fbc00fb --- /dev/null +++ b/src/assets/icons/home/manager-home-banner-plus.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/icons/home/manager-workspace-modal-plus.svg b/src/assets/icons/home/manager-workspace-modal-plus.svg new file mode 100644 index 0000000..98ce654 --- /dev/null +++ b/src/assets/icons/home/manager-workspace-modal-plus.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/manager-home-banner.jpg b/src/assets/manager-home-banner.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9da6f65d6d72cfcc865fbc842da610aacd7b9c7e GIT binary patch literal 101952 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 { + const dayText = String(day).padStart(2, '0') + const dateKey = `2026-01-${dayText}` + + return { + shiftId: index + 1, + workspaceName: '가게 이름', + position: '알바', + status: 'CONFIRMED', + startDateTime: `${dateKey}T10:00:00+09:00`, + endDateTime: `${dateKey}T14:00:00+09:00`, + dateKey, + startTimeLabel: '10:00', + endTimeLabel: '14:00', + durationHours: 4, + } + }), +} + +export function useManagerHomeViewModel() { + const [isWorkspaceChangeModalOpen, setIsWorkspaceChangeModalOpen] = + useState(false) + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(1) + + useEffect(() => { + if (!isWorkspaceChangeModalOpen) return + + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + + return () => { + document.body.style.overflow = previousOverflow + } + }, [isWorkspaceChangeModalOpen]) + + useEffect(() => { + if (!isWorkspaceChangeModalOpen) return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsWorkspaceChangeModalOpen(false) + } + } + + window.addEventListener('keydown', handleKeyDown) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [isWorkspaceChangeModalOpen]) + + return { + todayWorkers: TODAY_WORKERS, + storeWorkers: STORE_WORKERS, + ongoingPostings: ONGOING_POSTINGS, + substituteRequests: SUBSTITUTE_REQUESTS, + schedule: { + baseDate: MANAGER_SCHEDULE_BASE_DATE, + selectedDateKey: MANAGER_SCHEDULE_SELECTED_DATE_KEY, + estimatedEarningsText: MANAGER_SCHEDULE_ESTIMATED_EARNINGS_TEXT, + data: MANAGER_SCHEDULE_DATA, + }, + workspaceChangeModal: { + isOpen: isWorkspaceChangeModalOpen, + items: WORKSPACE_CHANGE_ITEMS, + selectedWorkspaceId, + }, + openWorkspaceChangeModal: () => setIsWorkspaceChangeModalOpen(true), + closeWorkspaceChangeModal: () => setIsWorkspaceChangeModalOpen(false), + selectWorkspace: (workspaceId: number) => + setSelectedWorkspaceId(workspaceId), + } +} diff --git a/src/features/home/user/schedule/ui/MonthlyCalendar.tsx b/src/features/home/user/schedule/ui/MonthlyCalendar.tsx index 17c6d3d..b86695e 100644 --- a/src/features/home/user/schedule/ui/MonthlyCalendar.tsx +++ b/src/features/home/user/schedule/ui/MonthlyCalendar.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react' import DownIcon from '@/assets/icons/home/chevron-down.svg?react' import { useMonthlyCalendarViewModel } from '@/features/home/user/schedule/hooks/useMonthlyCalendarViewModel' import type { MonthlyCalendarPropsBase } from '@/features/home/user/schedule/types/monthlyCalendar' @@ -5,6 +6,10 @@ import { MonthlyDateCell } from '@/features/home/user/schedule/ui/MonthlyDateCel interface MonthlyCalendarProps extends MonthlyCalendarPropsBase { isLoading?: boolean + hideTitle?: boolean + rightAction?: ReactNode + estimatedEarningsText?: string + layout?: 'default' | 'manager' } export function MonthlyCalendar({ @@ -13,6 +18,10 @@ export function MonthlyCalendar({ workspaceName, isLoading = false, selectedDateKey, + hideTitle = false, + rightAction, + estimatedEarningsText, + layout = 'default', }: MonthlyCalendarProps) { const { title, @@ -37,22 +46,34 @@ export function MonthlyCalendar({ return (
-
-

{title}

- -
- {totalWorkHoursText} - 시간 근무해요 +
+ {!hideTitle &&

{title}

} + +
+ + {rightAction} +
+ +
+
+ {totalWorkHoursText} + 시간 근무해요 +
+ {estimatedEarningsText ? ( + + {estimatedEarningsText} + + ) : null}
-
+
{weekdayLabels.map((label, index) => ( +
-
+
logo +
+ +
+
+

가게 이름

+ +
+

주소

+

가게 이름

+
+ +
+ {workspaceChangeModal.isOpen && ( +
+ +
+
+ )} +
MM월 dd일
전체 보기
- +
+
+

+ 우리 매장 시간표 +

+ + + + } + /> +
+

우리 매장 근무자

-
- {STORE_WORKERS.map(worker => ( +
+ {storeWorkers.map(worker => ( {}} /> ))} -
-
- +
@@ -140,7 +175,7 @@ export function ManagerHomePage() {
{}} onPostingClick={() => {}} /> @@ -152,7 +187,7 @@ export function ManagerHomePage() {
{}} onRequestClick={() => {}} /> diff --git a/src/shared/ui/manager/OngoingPostingCard.tsx b/src/shared/ui/manager/OngoingPostingCard.tsx index a8a9987..6b1eda2 100644 --- a/src/shared/ui/manager/OngoingPostingCard.tsx +++ b/src/shared/ui/manager/OngoingPostingCard.tsx @@ -1,4 +1,7 @@ // 진행 중인 공고 카드 +import { MoreButton } from '@/shared/ui/common/MoreButton' +import clockIcon from '@/assets/icons/alba/Clock.svg' +import calendarIcon from '@/assets/icons/alba/Calendar.svg' export interface JobPostingItem { id: string @@ -15,6 +18,33 @@ interface OngoingPostingCardProps { onPostingClick?: (posting: JobPostingItem) => void } +function parseWage(wage: string) { + const match = wage.match(/(\d[\d,]*)/) + + if (!match || !match[1]) { + return { + prefix: '시급', + amount: wage, + suffix: '', + } + } + + const amount = match[1] + const parts = wage.split(amount) + const [rawPrefix = '', rawSuffix = ''] = parts + + return { + prefix: rawPrefix.trim() || '시급', + amount, + suffix: rawSuffix.trim(), + } +} + +function parseDdayValue(dDay: string) { + const value = Number(dDay.replace(/[^0-9]/g, '')) + return Number.isNaN(value) ? null : value +} + function PostingRow({ posting, onClick, @@ -24,26 +54,49 @@ function PostingRow({ onClick?: () => void isLast: boolean }) { + const dDayValue = parseDdayValue(posting.dDay) + const isUrgent = dDayValue !== null && dDayValue <= 3 + const { prefix, amount, suffix } = parseWage(posting.wage) + return ( ) @@ -55,8 +108,8 @@ export function OngoingPostingCard({ onPostingClick, }: OngoingPostingCardProps) { return ( -
-
+
+
{postings.map((posting, index) => ( ))}
-
- +
+
) diff --git a/src/shared/ui/manager/SubstituteApprovalCard.tsx b/src/shared/ui/manager/SubstituteApprovalCard.tsx index ec79b1c..a423caa 100644 --- a/src/shared/ui/manager/SubstituteApprovalCard.tsx +++ b/src/shared/ui/manager/SubstituteApprovalCard.tsx @@ -1,4 +1,5 @@ // 대타 승인 요청 카드 +import { MoreButton } from '@/shared/ui/common/MoreButton' export type SubstituteRequestStatus = 'accepted' | 'pending' @@ -24,12 +25,12 @@ const statusConfig: Record< accepted: { label: '수락됨', className: - 'bg-[#E8F5F1] border-[#3A9982]/30 text-[#3A9982] typography-body03-semibold', + 'border-[#2CE283] bg-[#EAFDF3] text-[#2CE283] typography-body02-semibold', }, pending: { label: '대기중', className: - 'bg-amber-50 border-amber-300/50 text-amber-700 typography-body03-semibold', + 'border-[#E28D2C] bg-[#FDF8EA] text-[#E28D2C] typography-body02-semibold', }, } @@ -48,37 +49,39 @@ function RequestRow({ +
+
) From a069cb870f37af560187ea86bb295950ed688b23 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Thu, 16 Apr 2026 17:46:14 +0900 Subject: [PATCH 19/39] =?UTF-8?q?feat:=20=EC=82=AC=EC=9E=A5=EB=8B=98=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A4=84=20=EA=B4=80=EB=A6=AC=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 디자인 완료 이후에 나머지 구현 예정 --- src/app/App.tsx | 5 + .../hooks/useWorkerScheduleManageViewModel.ts | 45 +++++ .../home/user/schedule/ui/MonthlyCalendar.tsx | 4 +- src/pages/manager/home/index.tsx | 3 + src/pages/manager/worker-schedule/index.tsx | 170 ++++++++++++++++++ 5 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 src/features/home/manager/hooks/useWorkerScheduleManageViewModel.ts create mode 100644 src/pages/manager/worker-schedule/index.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index a4b9f1b..54ca404 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,6 +7,7 @@ import { Outlet, } from 'react-router-dom' import { ManagerHomePage } from '@/pages/manager/home' +import { ManagerWorkerSchedulePage } from '@/pages/manager/worker-schedule' import { SocialPage } from '@/pages/manager/social' import { SocialChatPage } from '@/pages/manager/social-chat' import { LoginPage } from '@/pages/login' @@ -72,6 +73,10 @@ export function App() { element={} /> } /> + } + /> }> diff --git a/src/features/home/manager/hooks/useWorkerScheduleManageViewModel.ts b/src/features/home/manager/hooks/useWorkerScheduleManageViewModel.ts new file mode 100644 index 0000000..35787bc --- /dev/null +++ b/src/features/home/manager/hooks/useWorkerScheduleManageViewModel.ts @@ -0,0 +1,45 @@ +import { useMemo, useState } from 'react' + +const WORKDAY_OPTIONS = ['월', '화', '수', '목', '금', '토', '일'] as const + +const DEFAULT_SELECTED_DAYS = ['수', '금'] + +const DEFAULT_TIME = { + startHour: '00', + startMinute: '00', + endHour: '00', + endMinute: '00', +} + +export function useWorkerScheduleManageViewModel() { + const [selectedDays, setSelectedDays] = useState( + DEFAULT_SELECTED_DAYS + ) + + const workTimeRangeLabel = useMemo( + () => + `${DEFAULT_TIME.startHour}:${DEFAULT_TIME.startMinute} ~ ${DEFAULT_TIME.endHour}:${DEFAULT_TIME.endMinute}`, + [] + ) + + function toggleDay(day: string) { + setSelectedDays(prev => + prev.includes(day) ? prev.filter(item => item !== day) : [...prev, day] + ) + } + + return { + worker: { + name: '이름임', + role: 'manager' as const, + }, + workdayOptions: WORKDAY_OPTIONS, + selectedDays, + workTimeRangeLabel, + startHour: DEFAULT_TIME.startHour, + startMinute: DEFAULT_TIME.startMinute, + endHour: DEFAULT_TIME.endHour, + endMinute: DEFAULT_TIME.endMinute, + toggleDay, + } +} diff --git a/src/features/home/user/schedule/ui/MonthlyCalendar.tsx b/src/features/home/user/schedule/ui/MonthlyCalendar.tsx index b86695e..bc10f85 100644 --- a/src/features/home/user/schedule/ui/MonthlyCalendar.tsx +++ b/src/features/home/user/schedule/ui/MonthlyCalendar.tsx @@ -73,7 +73,9 @@ export function MonthlyCalendar({
-
+
{weekdayLabels.map((label, index) => ( navigate('/manager/worker-schedule')} > + {value} + + {unit} + +
+ ) +} + +export function ManagerWorkerSchedulePage() { + const navigate = useNavigate() + const { + worker, + workdayOptions, + selectedDays, + workTimeRangeLabel, + startHour, + startMinute, + endHour, + endMinute, + toggleDay, + } = useWorkerScheduleManageViewModel() + + return ( +
+
+ +

+ 근무자 스케줄 관리 +

+
+ +
+
+

근무자 선택

+
+
+ + +
+
+ +
+

근무일 선택

+
+ {workdayOptions.map(day => { + const selected = selectedDays.includes(day) + return ( + + ) + })} +
+
+ +
+

+ 근무 시간 선택 +

+

+ {workTimeRangeLabel} +

+ +
+ + 출근 시간 + +
+ +
+
+ +
+
+ +
+ + 퇴근 시간 + +
+ +
+
+ +
+
+
+
+ +
+ +
+
+ ) +} From 9fa7621ac40b7f0d6d1ac01267defeb34f94536b Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Thu, 16 Apr 2026 19:25:28 +0900 Subject: [PATCH 20/39] =?UTF-8?q?feat:=20=EC=82=AC=EC=9E=A5=EB=8B=98=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=ED=95=98=EB=8A=94=20=EC=97=85=EC=9E=A5=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=B0=8F=20=EC=83=81=EC=84=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/manager/api/workspace.ts | 21 ++++++ .../hooks/useManagedWorkspacesQuery.ts | 39 ++++++++++ .../manager/hooks/useManagerHomeViewModel.ts | 53 ++++---------- .../manager/hooks/useWorkspaceDetailQuery.ts | 24 +++++++ src/features/home/manager/types/workspace.ts | 72 +++++++++++++++++++ .../home/manager/ui/WorkspaceChangeCard.tsx | 2 +- src/pages/manager/home/index.tsx | 19 +++-- src/shared/lib/queryKeys.ts | 5 ++ src/shared/stores/useWorkspaceStore.ts | 19 +++++ 9 files changed, 209 insertions(+), 45 deletions(-) create mode 100644 src/features/home/manager/api/workspace.ts create mode 100644 src/features/home/manager/hooks/useManagedWorkspacesQuery.ts create mode 100644 src/features/home/manager/hooks/useWorkspaceDetailQuery.ts create mode 100644 src/features/home/manager/types/workspace.ts create mode 100644 src/shared/stores/useWorkspaceStore.ts diff --git a/src/features/home/manager/api/workspace.ts b/src/features/home/manager/api/workspace.ts new file mode 100644 index 0000000..ee0d624 --- /dev/null +++ b/src/features/home/manager/api/workspace.ts @@ -0,0 +1,21 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + ManagedWorkspacesApiResponse, + WorkspaceDetailApiResponse, +} from '@/features/home/manager/types/workspace' + +export async function fetchManagedWorkspaces(): Promise { + const response = await axiosInstance.get( + '/manager/workspaces' + ) + return response.data +} + +export async function fetchWorkspaceDetail( + workspaceId: number +): Promise { + const response = await axiosInstance.get( + `/manager/workspaces/${workspaceId}` + ) + return response.data +} diff --git a/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts b/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts new file mode 100644 index 0000000..5b6e0ca --- /dev/null +++ b/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts @@ -0,0 +1,39 @@ +import { useEffect, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { fetchManagedWorkspaces } from '@/features/home/manager/api/workspace' +import { adaptWorkspaceItemDto } from '@/features/home/manager/types/workspace' +import { useWorkspaceStore } from '@/shared/stores/useWorkspaceStore' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useManagedWorkspacesQuery() { + const { activeWorkspaceId, setActiveWorkspaceId } = useWorkspaceStore() + + const { data, isPending, isError } = useQuery({ + queryKey: queryKeys.managerWorkspace.list(), + queryFn: fetchManagedWorkspaces, + }) + + const workspaces = useMemo( + () => data?.data.map(adaptWorkspaceItemDto) ?? [], + [data] + ) + + // ID가 가장 작은 업장을 기본값으로 설정 + useEffect(() => { + if (workspaces.length === 0) return + if (activeWorkspaceId !== null) return + + const defaultWorkspace = workspaces.reduce((prev, curr) => + curr.id < prev.id ? curr : prev + ) + setActiveWorkspaceId(defaultWorkspace.id) + }, [workspaces, activeWorkspaceId, setActiveWorkspaceId]) + + return { + workspaces, + activeWorkspaceId, + setActiveWorkspaceId, + isLoading: isPending, + isError, + } +} diff --git a/src/features/home/manager/hooks/useManagerHomeViewModel.ts b/src/features/home/manager/hooks/useManagerHomeViewModel.ts index 8a5fad3..3ca84ce 100644 --- a/src/features/home/manager/hooks/useManagerHomeViewModel.ts +++ b/src/features/home/manager/hooks/useManagerHomeViewModel.ts @@ -1,10 +1,11 @@ import { useEffect, useState } from 'react' import type { TodayWorkerItem } from '@/features/home/manager/ui/TodayWorkerList' import type { StoreWorkerRole } from '@/features/home/manager/ui/StoreWorkerListItem' -import type { WorkspaceChangeItem } from '@/features/home/manager/ui/WorkspaceChangeCard' import type { CalendarViewData } from '@/features/home/user/schedule/types/schedule' import type { JobPostingItem } from '@/shared/ui/manager/OngoingPostingCard' import type { SubstituteRequestItem } from '@/shared/ui/manager/SubstituteApprovalCard' +import { useManagedWorkspacesQuery } from '@/features/home/manager/hooks/useManagedWorkspacesQuery' +import { useWorkspaceDetailQuery } from '@/features/home/manager/hooks/useWorkspaceDetailQuery' export interface ManagerStoreWorkerData { id: string @@ -84,39 +85,6 @@ const MANAGER_SCHEDULE_DAYS = [ 1, 2, 3, 4, 9, 10, 11, 16, 17, 18, 23, 24, 25, 30, 31, ] -const WORKSPACE_CHANGE_ITEMS: WorkspaceChangeItem[] = [ - { - id: 1, - businessName: '가게 이름', - fullAddress: '주소', - createdAt: '2026-01-01', - status: { - value: 'ACTIVE', - description: '활성', - }, - }, - { - id: 2, - businessName: '가게 이름', - fullAddress: '주소', - createdAt: '2026-01-01', - status: { - value: 'ACTIVE', - description: '활성', - }, - }, - { - id: 3, - businessName: '가게 이름', - fullAddress: '주소', - createdAt: '2026-01-01', - status: { - value: 'ACTIVE', - description: '활성', - }, - }, -] - const MANAGER_SCHEDULE_DATA: CalendarViewData = { summary: { totalWorkHours: 60, @@ -144,7 +112,11 @@ const MANAGER_SCHEDULE_DATA: CalendarViewData = { export function useManagerHomeViewModel() { const [isWorkspaceChangeModalOpen, setIsWorkspaceChangeModalOpen] = useState(false) - const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(1) + + const { workspaces, activeWorkspaceId, setActiveWorkspaceId } = + useManagedWorkspacesQuery() + + const { detail: workspaceDetail } = useWorkspaceDetailQuery(activeWorkspaceId) useEffect(() => { if (!isWorkspaceChangeModalOpen) return @@ -184,14 +156,17 @@ export function useManagerHomeViewModel() { estimatedEarningsText: MANAGER_SCHEDULE_ESTIMATED_EARNINGS_TEXT, data: MANAGER_SCHEDULE_DATA, }, + workspaceDetail, workspaceChangeModal: { isOpen: isWorkspaceChangeModalOpen, - items: WORKSPACE_CHANGE_ITEMS, - selectedWorkspaceId, + items: workspaces, + selectedWorkspaceId: activeWorkspaceId, }, openWorkspaceChangeModal: () => setIsWorkspaceChangeModalOpen(true), closeWorkspaceChangeModal: () => setIsWorkspaceChangeModalOpen(false), - selectWorkspace: (workspaceId: number) => - setSelectedWorkspaceId(workspaceId), + selectWorkspace: (workspaceId: number) => { + setActiveWorkspaceId(workspaceId) + setIsWorkspaceChangeModalOpen(false) + }, } } diff --git a/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts b/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts new file mode 100644 index 0000000..c945f73 --- /dev/null +++ b/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts @@ -0,0 +1,24 @@ +import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { fetchWorkspaceDetail } from '@/features/home/manager/api/workspace' +import { adaptWorkspaceDetailDto } from '@/features/home/manager/types/workspace' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useWorkspaceDetailQuery(workspaceId: number | null) { + const { data, isPending, isError } = useQuery({ + queryKey: queryKeys.managerWorkspace.detail(workspaceId ?? 0), + queryFn: () => fetchWorkspaceDetail(workspaceId!), + enabled: workspaceId !== null, + }) + + const detail = useMemo( + () => (data?.data ? adaptWorkspaceDetailDto(data.data) : null), + [data] + ) + + return { + detail, + isLoading: isPending && workspaceId !== null, + isError, + } +} diff --git a/src/features/home/manager/types/workspace.ts b/src/features/home/manager/types/workspace.ts new file mode 100644 index 0000000..bec37d8 --- /dev/null +++ b/src/features/home/manager/types/workspace.ts @@ -0,0 +1,72 @@ +import type { CommonApiResponse } from '@/shared/types/common' + +// ---- API DTOs ---- +export interface WorkspaceStatusDto { + value: string + description: string +} + +export interface WorkspaceItemDto { + id: number + businessName: string + fullAddress: string + status: WorkspaceStatusDto +} + +export interface WorkspaceDetailDto { + id: number + businessName: string + businessType: string + contact: string + description: string + fullAddress: string + reputationSummary: string +} + +// ---- API Response Types ---- +export type ManagedWorkspacesApiResponse = CommonApiResponse +export type WorkspaceDetailApiResponse = CommonApiResponse + +// ---- UI Models ---- +export interface ManagerWorkspaceItem { + id: number + businessName: string + fullAddress: string + status: WorkspaceStatusDto +} + +export interface ManagerWorkspaceDetail { + id: number + businessName: string + businessType: string + contact: string + description: string + fullAddress: string + reputationSummary: string +} + +// ---- Adapters ---- +export function adaptWorkspaceItemDto( + dto: WorkspaceItemDto +): ManagerWorkspaceItem { + return { + id: dto.id, + businessName: dto.businessName, + fullAddress: dto.fullAddress, + status: dto.status, + } +} + +export function adaptWorkspaceDetailDto( + dto: WorkspaceDetailDto +): ManagerWorkspaceDetail { + return { + id: dto.id, + businessName: dto.businessName, + businessType: dto.businessType, + contact: dto.contact, + description: dto.description, + fullAddress: dto.fullAddress, + reputationSummary: dto.reputationSummary, + } +} diff --git a/src/features/home/manager/ui/WorkspaceChangeCard.tsx b/src/features/home/manager/ui/WorkspaceChangeCard.tsx index 2005040..f0cac9b 100644 --- a/src/features/home/manager/ui/WorkspaceChangeCard.tsx +++ b/src/features/home/manager/ui/WorkspaceChangeCard.tsx @@ -10,7 +10,7 @@ interface WorkspaceChangeItem { id: number businessName: string fullAddress: string - createdAt: string + createdAt?: string status: WorkspaceStatus } diff --git a/src/pages/manager/home/index.tsx b/src/pages/manager/home/index.tsx index a2eac19..5450410 100644 --- a/src/pages/manager/home/index.tsx +++ b/src/pages/manager/home/index.tsx @@ -22,6 +22,7 @@ export function ManagerHomePage() { ongoingPostings, substituteRequests, schedule, + workspaceDetail, workspaceChangeModal, openWorkspaceChangeModal, closeWorkspaceChangeModal, @@ -42,11 +43,17 @@ export function ManagerHomePage() {
-

가게 이름

- +

+ {workspaceDetail?.businessName ?? ''} +

+
-

주소

-

가게 이름

+

+ {workspaceDetail?.fullAddress ?? ''} +

+

+ {workspaceDetail?.businessName ?? ''} +

diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index 6436156..8352943 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -26,5 +26,7 @@ export const queryKeys = { list: () => ['managerWorkspace', 'list'] as const, detail: (workspaceId: number) => ['managerWorkspace', 'detail', workspaceId] as const, + workers: (workspaceId: number, params?: { status?: string; name?: string; pageSize?: number }) => + ['managerWorkspace', 'workers', workspaceId, params] as const, }, } as const From cb9657b7945e465784b26d5639752a7090bc1290 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Thu, 16 Apr 2026 20:05:04 +0900 Subject: [PATCH 22/39] =?UTF-8?q?feat:=20=EB=82=B4=EA=B0=80=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=ED=95=9C=20=EA=B3=B5=EA=B3=A0=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/manager/api/posting.ts | 22 ++++ .../hooks/useManagedPostingsViewModel.ts | 49 +++++++++ .../manager/hooks/useManagerHomeViewModel.ts | 40 ++----- src/features/home/manager/types/posting.ts | 103 ++++++++++++++++++ src/pages/manager/home/index.tsx | 8 +- src/shared/lib/queryKeys.ts | 4 + 6 files changed, 196 insertions(+), 30 deletions(-) create mode 100644 src/features/home/manager/api/posting.ts create mode 100644 src/features/home/manager/hooks/useManagedPostingsViewModel.ts create mode 100644 src/features/home/manager/types/posting.ts diff --git a/src/features/home/manager/api/posting.ts b/src/features/home/manager/api/posting.ts new file mode 100644 index 0000000..f94e8e2 --- /dev/null +++ b/src/features/home/manager/api/posting.ts @@ -0,0 +1,22 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + PostingListApiResponse, + ManagedPostingsQueryParams, +} from '@/features/home/manager/types/posting' + +export async function fetchManagedPostings( + params: ManagedPostingsQueryParams +): 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 response.data +} diff --git a/src/features/home/manager/hooks/useManagedPostingsViewModel.ts b/src/features/home/manager/hooks/useManagedPostingsViewModel.ts new file mode 100644 index 0000000..c8922d0 --- /dev/null +++ b/src/features/home/manager/hooks/useManagedPostingsViewModel.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' +import { fetchManagedPostings } from '@/features/home/manager/api/posting' +import { adaptPostingDto } from '@/features/home/manager/types/posting' +import { queryKeys } from '@/shared/lib/queryKeys' + +const PAGE_SIZE = 10 + +export function useManagedPostingsViewModel( + workspaceId: number | null, + params?: { status?: string } +) { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isError } = + useInfiniteQuery({ + queryKey: queryKeys.posting.list({ + workspaceId: workspaceId ?? undefined, + status: params?.status, + pageSize: PAGE_SIZE, + }), + queryFn: ({ pageParam }) => + fetchManagedPostings({ + pageSize: PAGE_SIZE, + workspaceId: workspaceId ?? undefined, + status: params?.status, + cursor: pageParam as string | undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, + enabled: workspaceId !== null, + }) + + const postings = useMemo( + () => + data?.pages.flatMap(page => page.data.data.map(adaptPostingDto)) ?? [], + [data] + ) + + const totalCount = data?.pages[0]?.data.page.totalCount ?? 0 + + return { + postings, + totalCount, + fetchNextPage, + hasNextPage: !!hasNextPage, + isFetchingNextPage, + isLoading: isPending && workspaceId !== null, + isError, + } +} diff --git a/src/features/home/manager/hooks/useManagerHomeViewModel.ts b/src/features/home/manager/hooks/useManagerHomeViewModel.ts index 287022b..9d5ed0a 100644 --- a/src/features/home/manager/hooks/useManagerHomeViewModel.ts +++ b/src/features/home/manager/hooks/useManagerHomeViewModel.ts @@ -1,11 +1,11 @@ import { useEffect, useState } from 'react' import type { TodayWorkerItem } from '@/features/home/manager/ui/TodayWorkerList' import type { CalendarViewData } from '@/features/home/user/schedule/types/schedule' -import type { JobPostingItem } from '@/shared/ui/manager/OngoingPostingCard' import type { SubstituteRequestItem } from '@/shared/ui/manager/SubstituteApprovalCard' import { useManagedWorkspacesQuery } from '@/features/home/manager/hooks/useManagedWorkspacesQuery' import { useWorkspaceDetailQuery } from '@/features/home/manager/hooks/useWorkspaceDetailQuery' import { useWorkspaceWorkersViewModel } from '@/features/home/manager/hooks/useWorkspaceWorkersViewModel' +import { useManagedPostingsViewModel } from '@/features/home/manager/hooks/useManagedPostingsViewModel' const TODAY_WORKERS: TodayWorkerItem[] = [ { id: '1', name: '알바생1', workTime: '00:00 ~ 00:00' }, @@ -13,32 +13,6 @@ const TODAY_WORKERS: TodayWorkerItem[] = [ ] -const ONGOING_POSTINGS: JobPostingItem[] = [ - { - id: '1', - dDay: 'D-3', - title: '[가게이름] 평일 저녁 마감 근무자 모집', - wage: '시급 10,030원', - workHours: '17:00 ~ 21:00', - workDays: '수, 목, 금', - }, - { - id: '2', - dDay: 'D-7', - title: '[가게이름] 평일 저녁 마감 근무자 모집', - wage: '시급 10,030원', - workHours: '07:00 ~ 13:00', - workDays: '월, 화, 수', - }, - { - id: '3', - dDay: 'D-27', - title: '[가게이름] 평일 저녁 마감 근무자 모집', - wage: '시급 10,030원', - workHours: '07:00 ~ 13:00', - workDays: '월, 화, 수', - }, -] const SUBSTITUTE_REQUESTS: SubstituteRequestItem[] = [ { @@ -112,6 +86,13 @@ export function useManagerHomeViewModel() { isFetchingNextPage: isFetchingMoreWorkers, } = useWorkspaceWorkersViewModel(activeWorkspaceId) + const { + postings: ongoingPostings, + totalCount: postingsTotalCount, + fetchNextPage: fetchMorePostings, + hasNextPage: hasMorePostings, + } = useManagedPostingsViewModel(activeWorkspaceId, { status: 'OPEN' }) + useEffect(() => { if (!isWorkspaceChangeModalOpen) return @@ -145,7 +126,10 @@ export function useManagerHomeViewModel() { fetchMoreWorkers, hasMoreWorkers, isFetchingMoreWorkers, - ongoingPostings: ONGOING_POSTINGS, + ongoingPostings, + postingsTotalCount, + fetchMorePostings, + hasMorePostings, substituteRequests: SUBSTITUTE_REQUESTS, schedule: { baseDate: MANAGER_SCHEDULE_BASE_DATE, diff --git a/src/features/home/manager/types/posting.ts b/src/features/home/manager/types/posting.ts new file mode 100644 index 0000000..8840a6d --- /dev/null +++ b/src/features/home/manager/types/posting.ts @@ -0,0 +1,103 @@ +import type { CommonApiResponse } from '@/shared/types/common' +import type { JobPostingItem } from '@/shared/ui/manager/OngoingPostingCard' + +// ---- API DTOs ---- +export interface PostingKeywordDto { + id: number + name: string +} + +export interface PostingScheduleDto { + workingDays: string[] + startTime: string + endTime: string + positionsNeeded: number + position: number +} + +export interface PostingWorkspaceDto { + id: number + businessName: string +} + +export interface PostingDto { + id: number + title: string + payAmount: number + paymentType: string + createdAt: string + keywords: PostingKeywordDto[] + schedules: PostingScheduleDto[] + workspace: PostingWorkspaceDto +} + +export interface PostingPageDto { + cursor: string | null + pageSize: number + totalCount: number +} + +export type PostingListApiResponse = CommonApiResponse<{ + page: PostingPageDto + data: PostingDto[] +}> + +// ---- Query Params ---- +export interface ManagedPostingsQueryParams { + workspaceId?: number + status?: string + cursor?: string + pageSize: number +} + +// ---- Mappers ---- +const PAYMENT_TYPE_LABEL: Record = { + HOURLY: '시급', + DAILY: '일급', + MONTHLY: '월급', + WEEKLY: '주급', +} + +const WORKING_DAY_KO: Record = { + MONDAY: '월', + TUESDAY: '화', + WEDNESDAY: '수', + THURSDAY: '목', + FRIDAY: '금', + SATURDAY: '토', + SUNDAY: '일', +} + +function formatWage(payAmount: number, paymentType: string): string { + const label = PAYMENT_TYPE_LABEL[paymentType] ?? '시급' + const amount = payAmount.toLocaleString('ko-KR') + return `${label} ${amount}원` +} + +function formatWorkHours(schedules: PostingScheduleDto[]): string { + if (schedules.length === 0) return '-' + const first = schedules[0] + const base = `${first.startTime} ~ ${first.endTime}` + return schedules.length > 1 ? `${base} 외 ${schedules.length - 1}개` : base +} + +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)) + .map(d => WORKING_DAY_KO[d] ?? d) + .join(', ') +} + +export function adaptPostingDto(dto: PostingDto): JobPostingItem { + return { + id: String(dto.id), + dDay: '', + title: dto.title, + wage: formatWage(dto.payAmount, dto.paymentType), + workHours: formatWorkHours(dto.schedules), + workDays: formatWorkDays(dto.schedules), + } +} diff --git a/src/pages/manager/home/index.tsx b/src/pages/manager/home/index.tsx index 992cac0..64cf563 100644 --- a/src/pages/manager/home/index.tsx +++ b/src/pages/manager/home/index.tsx @@ -23,6 +23,9 @@ export function ManagerHomePage() { hasMoreWorkers, isFetchingMoreWorkers, ongoingPostings, + postingsTotalCount, + fetchMorePostings, + hasMorePostings, substituteRequests, schedule, workspaceDetail, @@ -191,12 +194,13 @@ export function ManagerHomePage() {

- 진행 중인 공고 10건 + 진행 중인 공고{' '} + {postingsTotalCount}

{}} + onViewMore={hasMorePostings ? () => fetchMorePostings() : undefined} onPostingClick={() => {}} />
diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index 8352943..a70391c 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -29,4 +29,8 @@ export const queryKeys = { workers: (workspaceId: number, params?: { status?: string; name?: string; pageSize?: number }) => ['managerWorkspace', 'workers', workspaceId, params] as const, }, + posting: { + list: (params?: { workspaceId?: number; status?: string; pageSize?: number }) => + ['posting', 'list', params] as const, + }, } as const From aa4c5e0fe045b4d7a7d2a3ea8bc8e128a35f0314 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Thu, 16 Apr 2026 20:12:25 +0900 Subject: [PATCH 23/39] =?UTF-8?q?feat:=20=EC=82=AC=EC=9E=A5=EB=8B=98=20?= =?UTF-8?q?=EB=8C=80=ED=83=80=20=EC=9A=94=EC=B2=AD=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/manager/api/posting.ts | 4 +- src/features/home/manager/api/substitute.ts | 24 ++++++ .../hooks/useManagedPostingsViewModel.ts | 36 ++++---- .../manager/hooks/useManagerHomeViewModel.ts | 40 +++------ .../hooks/useSubstituteRequestsViewModel.ts | 57 +++++++++++++ .../hooks/useWorkspaceWorkersViewModel.ts | 41 +++++---- src/features/home/manager/types/posting.ts | 10 ++- src/features/home/manager/types/substitute.ts | 83 +++++++++++++++++++ src/pages/manager/home/index.tsx | 14 +++- src/shared/lib/queryKeys.ts | 20 ++++- 10 files changed, 258 insertions(+), 71 deletions(-) create mode 100644 src/features/home/manager/api/substitute.ts create mode 100644 src/features/home/manager/hooks/useSubstituteRequestsViewModel.ts create mode 100644 src/features/home/manager/types/substitute.ts diff --git a/src/features/home/manager/api/posting.ts b/src/features/home/manager/api/posting.ts index f94e8e2..b8271af 100644 --- a/src/features/home/manager/api/posting.ts +++ b/src/features/home/manager/api/posting.ts @@ -12,7 +12,9 @@ export async function fetchManagedPostings( { params: { pageSize: params.pageSize, - ...(params.workspaceId !== undefined && { workspaceId: params.workspaceId }), + ...(params.workspaceId !== undefined && { + workspaceId: params.workspaceId, + }), ...(params.status && { status: params.status }), ...(params.cursor !== undefined && { cursor: params.cursor }), }, diff --git a/src/features/home/manager/api/substitute.ts b/src/features/home/manager/api/substitute.ts new file mode 100644 index 0000000..ae01a83 --- /dev/null +++ b/src/features/home/manager/api/substitute.ts @@ -0,0 +1,24 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + SubstituteListApiResponse, + SubstituteRequestsQueryParams, +} from '@/features/home/manager/types/substitute' + +export async function fetchSubstituteRequests( + params: SubstituteRequestsQueryParams +): Promise { + const response = await axiosInstance.get( + '/manager/substitute-requests', + { + params: { + pageSize: params.pageSize, + ...(params.workspaceId !== undefined && { + workspaceId: params.workspaceId, + }), + ...(params.status && { status: params.status }), + ...(params.cursor !== undefined && { cursor: params.cursor }), + }, + } + ) + return response.data +} diff --git a/src/features/home/manager/hooks/useManagedPostingsViewModel.ts b/src/features/home/manager/hooks/useManagedPostingsViewModel.ts index c8922d0..03db2e6 100644 --- a/src/features/home/manager/hooks/useManagedPostingsViewModel.ts +++ b/src/features/home/manager/hooks/useManagedPostingsViewModel.ts @@ -10,24 +10,30 @@ export function useManagedPostingsViewModel( workspaceId: number | null, params?: { status?: string } ) { - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isError } = - useInfiniteQuery({ - queryKey: queryKeys.posting.list({ + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isError, + } = useInfiniteQuery({ + queryKey: queryKeys.posting.list({ + workspaceId: workspaceId ?? undefined, + status: params?.status, + pageSize: PAGE_SIZE, + }), + queryFn: ({ pageParam }) => + fetchManagedPostings({ + pageSize: PAGE_SIZE, workspaceId: workspaceId ?? undefined, status: params?.status, - pageSize: PAGE_SIZE, + cursor: pageParam as string | undefined, }), - queryFn: ({ pageParam }) => - fetchManagedPostings({ - pageSize: PAGE_SIZE, - workspaceId: workspaceId ?? undefined, - status: params?.status, - cursor: pageParam as string | undefined, - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, - enabled: workspaceId !== null, - }) + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, + enabled: workspaceId !== null, + }) const postings = useMemo( () => diff --git a/src/features/home/manager/hooks/useManagerHomeViewModel.ts b/src/features/home/manager/hooks/useManagerHomeViewModel.ts index 9d5ed0a..a5c9e33 100644 --- a/src/features/home/manager/hooks/useManagerHomeViewModel.ts +++ b/src/features/home/manager/hooks/useManagerHomeViewModel.ts @@ -1,43 +1,17 @@ import { useEffect, useState } from 'react' import type { TodayWorkerItem } from '@/features/home/manager/ui/TodayWorkerList' import type { CalendarViewData } from '@/features/home/user/schedule/types/schedule' -import type { SubstituteRequestItem } from '@/shared/ui/manager/SubstituteApprovalCard' import { useManagedWorkspacesQuery } from '@/features/home/manager/hooks/useManagedWorkspacesQuery' import { useWorkspaceDetailQuery } from '@/features/home/manager/hooks/useWorkspaceDetailQuery' import { useWorkspaceWorkersViewModel } from '@/features/home/manager/hooks/useWorkspaceWorkersViewModel' import { useManagedPostingsViewModel } from '@/features/home/manager/hooks/useManagedPostingsViewModel' +import { useSubstituteRequestsViewModel } from '@/features/home/manager/hooks/useSubstituteRequestsViewModel' const TODAY_WORKERS: TodayWorkerItem[] = [ { id: '1', name: '알바생1', workTime: '00:00 ~ 00:00' }, { id: '2', name: '알바생2', workTime: '00:00 ~ 00:00' }, ] - - -const SUBSTITUTE_REQUESTS: SubstituteRequestItem[] = [ - { - id: '1', - name: '나영채', - role: '알바', - dateRange: '1월 1일 ↔ 1월 10일', - status: 'accepted', - }, - { - id: '2', - name: '나영채', - role: '알바', - dateRange: '1월 1일 ↔ 1월 10일', - status: 'pending', - }, - { - id: '3', - name: '나영채', - role: '알바', - dateRange: '1월 1일 ↔ 1월 10일', - status: 'pending', - }, -] - const MANAGER_SCHEDULE_BASE_DATE = new Date('2026-01-08T00:00:00+09:00') const MANAGER_SCHEDULE_SELECTED_DATE_KEY = '2026-01-08' const MANAGER_SCHEDULE_ESTIMATED_EARNINGS_TEXT = '약 619,200원' @@ -93,6 +67,13 @@ export function useManagerHomeViewModel() { hasNextPage: hasMorePostings, } = useManagedPostingsViewModel(activeWorkspaceId, { status: 'OPEN' }) + const { + requests: substituteRequests, + totalCount: substituteTotalCount, + fetchNextPage: fetchMoreSubstitutes, + hasNextPage: hasMoreSubstitutes, + } = useSubstituteRequestsViewModel(activeWorkspaceId) + useEffect(() => { if (!isWorkspaceChangeModalOpen) return @@ -130,7 +111,10 @@ export function useManagerHomeViewModel() { postingsTotalCount, fetchMorePostings, hasMorePostings, - substituteRequests: SUBSTITUTE_REQUESTS, + substituteRequests, + substituteTotalCount, + fetchMoreSubstitutes, + hasMoreSubstitutes, schedule: { baseDate: MANAGER_SCHEDULE_BASE_DATE, selectedDateKey: MANAGER_SCHEDULE_SELECTED_DATE_KEY, diff --git a/src/features/home/manager/hooks/useSubstituteRequestsViewModel.ts b/src/features/home/manager/hooks/useSubstituteRequestsViewModel.ts new file mode 100644 index 0000000..8bbb15e --- /dev/null +++ b/src/features/home/manager/hooks/useSubstituteRequestsViewModel.ts @@ -0,0 +1,57 @@ +import { useMemo } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' +import { fetchSubstituteRequests } from '@/features/home/manager/api/substitute' +import { adaptSubstituteRequestDto } from '@/features/home/manager/types/substitute' +import { queryKeys } from '@/shared/lib/queryKeys' + +const PAGE_SIZE = 10 + +export function useSubstituteRequestsViewModel( + workspaceId: number | null, + params?: { status?: string } +) { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isError, + } = useInfiniteQuery({ + queryKey: queryKeys.substitute.list({ + workspaceId: workspaceId ?? undefined, + status: params?.status, + pageSize: PAGE_SIZE, + }), + queryFn: ({ pageParam }) => + fetchSubstituteRequests({ + pageSize: PAGE_SIZE, + workspaceId: workspaceId ?? undefined, + status: params?.status, + cursor: pageParam as string | undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, + enabled: workspaceId !== null, + }) + + const requests = useMemo( + () => + data?.pages.flatMap(page => + page.data.data.map(adaptSubstituteRequestDto) + ) ?? [], + [data] + ) + + const totalCount = data?.pages[0]?.data.page.totalCount ?? 0 + + return { + requests, + totalCount, + fetchNextPage, + hasNextPage: !!hasNextPage, + isFetchingNextPage, + isLoading: isPending && workspaceId !== null, + isError, + } +} diff --git a/src/features/home/manager/hooks/useWorkspaceWorkersViewModel.ts b/src/features/home/manager/hooks/useWorkspaceWorkersViewModel.ts index 9f7e1ee..95e3339 100644 --- a/src/features/home/manager/hooks/useWorkspaceWorkersViewModel.ts +++ b/src/features/home/manager/hooks/useWorkspaceWorkersViewModel.ts @@ -10,29 +10,34 @@ export function useWorkspaceWorkersViewModel( workspaceId: number | null, params?: { status?: string; name?: string } ) { - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isError } = - useInfiniteQuery({ - queryKey: queryKeys.managerWorkspace.workers(workspaceId ?? 0, { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isError, + } = useInfiniteQuery({ + queryKey: queryKeys.managerWorkspace.workers(workspaceId ?? 0, { + status: params?.status, + name: params?.name, + pageSize: PAGE_SIZE, + }), + queryFn: ({ pageParam }) => + fetchWorkspaceWorkers({ + workspaceId: workspaceId!, + pageSize: PAGE_SIZE, + cursor: pageParam as string | undefined, status: params?.status, name: params?.name, - pageSize: PAGE_SIZE, }), - queryFn: ({ pageParam }) => - fetchWorkspaceWorkers({ - workspaceId: workspaceId!, - pageSize: PAGE_SIZE, - cursor: pageParam as string | undefined, - status: params?.status, - name: params?.name, - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, - enabled: workspaceId !== null, - }) + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, + enabled: workspaceId !== null, + }) const workers = useMemo( - () => - data?.pages.flatMap(page => page.data.data.map(adaptWorkerDto)) ?? [], + () => data?.pages.flatMap(page => page.data.data.map(adaptWorkerDto)) ?? [], [data] ) diff --git a/src/features/home/manager/types/posting.ts b/src/features/home/manager/types/posting.ts index 8840a6d..d570ea2 100644 --- a/src/features/home/manager/types/posting.ts +++ b/src/features/home/manager/types/posting.ts @@ -84,7 +84,15 @@ 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 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)) .map(d => WORKING_DAY_KO[d] ?? d) diff --git a/src/features/home/manager/types/substitute.ts b/src/features/home/manager/types/substitute.ts new file mode 100644 index 0000000..6aca83d --- /dev/null +++ b/src/features/home/manager/types/substitute.ts @@ -0,0 +1,83 @@ +import type { CommonApiResponse } from '@/shared/types/common' +import type { SubstituteRequestItem } from '@/shared/ui/manager/SubstituteApprovalCard' + +// ---- API DTOs ---- +export interface SubstituteScheduleDto { + scheduleId: number + startDateTime: string + endDateTime: string + position: string +} + +export interface SubstituteRequesterDto { + workerId: number + workerName: string +} + +export interface SubstituteStatusDto { + value: string + description: string +} + +export interface SubstituteRequestTypeDto { + value: string + description: string +} + +export interface SubstituteRequestDto { + id: number + schedule: SubstituteScheduleDto + requester: SubstituteRequesterDto + requestType: SubstituteRequestTypeDto + status: SubstituteStatusDto + createdAt: string +} + +export interface SubstitutePageDto { + cursor: string | null + pageSize: number + totalCount: number +} + +export type SubstituteListApiResponse = CommonApiResponse<{ + page: SubstitutePageDto + data: SubstituteRequestDto[] +}> + +// ---- Query Params ---- +export interface SubstituteRequestsQueryParams { + workspaceId?: number + status?: string + cursor?: string + pageSize: number +} + +// ---- Mappers ---- +function mapApiStatusToUiStatus( + apiStatus: string +): SubstituteRequestItem['status'] { + if (apiStatus === 'ACCEPTED' || apiStatus === 'APPROVED') return 'accepted' + return 'pending' +} + +function formatDateRange(startDateTime: string, endDateTime: string): string { + const start = new Date(startDateTime) + const end = new Date(endDateTime) + const fmt = (d: Date) => `${d.getMonth() + 1}월 ${d.getDate()}일` + return `${fmt(start)} ↔ ${fmt(end)}` +} + +export function adaptSubstituteRequestDto( + dto: SubstituteRequestDto +): SubstituteRequestItem { + return { + id: String(dto.id), + name: dto.requester.workerName, + role: dto.schedule.position, + dateRange: formatDateRange( + dto.schedule.startDateTime, + dto.schedule.endDateTime + ), + status: mapApiStatusToUiStatus(dto.status.value), + } +} diff --git a/src/pages/manager/home/index.tsx b/src/pages/manager/home/index.tsx index 64cf563..f231156 100644 --- a/src/pages/manager/home/index.tsx +++ b/src/pages/manager/home/index.tsx @@ -27,6 +27,9 @@ export function ManagerHomePage() { fetchMorePostings, hasMorePostings, substituteRequests, + substituteTotalCount, + fetchMoreSubstitutes, + hasMoreSubstitutes, schedule, workspaceDetail, workspaceChangeModal, @@ -194,8 +197,8 @@ export function ManagerHomePage() {

- 진행 중인 공고{' '} - {postingsTotalCount}건 + 진행 중인 공고 {postingsTotalCount} + 건

- 대타 승인 요청 10건 + 대타 승인 요청{' '} + {substituteTotalCount}

{}} + onViewMore={ + hasMoreSubstitutes ? () => fetchMoreSubstitutes() : undefined + } onRequestClick={() => {}} />
diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index a70391c..1131f41 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -26,11 +26,23 @@ export const queryKeys = { list: () => ['managerWorkspace', 'list'] as const, detail: (workspaceId: number) => ['managerWorkspace', 'detail', workspaceId] as const, - workers: (workspaceId: number, params?: { status?: string; name?: string; pageSize?: number }) => - ['managerWorkspace', 'workers', workspaceId, params] as const, + workers: ( + workspaceId: number, + params?: { status?: string; name?: string; pageSize?: number } + ) => ['managerWorkspace', 'workers', workspaceId, params] as const, }, posting: { - list: (params?: { workspaceId?: number; status?: string; pageSize?: number }) => - ['posting', 'list', params] as const, + list: (params?: { + workspaceId?: number + status?: string + pageSize?: number + }) => ['posting', 'list', params] as const, + }, + substitute: { + list: (params?: { + workspaceId?: number + status?: string + pageSize?: number + }) => ['substitute', 'list', params] as const, }, } as const From 9a76e0c58f85cf53801f1f187150c37e32a1bc73 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Thu, 16 Apr 2026 20:22:37 +0900 Subject: [PATCH 24/39] =?UTF-8?q?feat:=20=EC=82=AC=EC=9E=A5=EB=8B=98=20?= =?UTF-8?q?=EC=97=85=EC=9E=A5=20=EC=8A=A4=EC=BC=80=EC=A4=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/manager/api/schedule.ts | 21 +++++ .../manager/hooks/useManagerHomeViewModel.ts | 53 ++++--------- .../hooks/useMonthlySchedulesViewModel.ts | 58 ++++++++++++++ src/features/home/manager/types/schedule.ts | 76 +++++++++++++++++++ src/pages/manager/home/index.tsx | 54 +++++++++---- src/shared/lib/queryKeys.ts | 4 + 6 files changed, 215 insertions(+), 51 deletions(-) create mode 100644 src/features/home/manager/api/schedule.ts create mode 100644 src/features/home/manager/hooks/useMonthlySchedulesViewModel.ts create mode 100644 src/features/home/manager/types/schedule.ts diff --git a/src/features/home/manager/api/schedule.ts b/src/features/home/manager/api/schedule.ts new file mode 100644 index 0000000..185d4f1 --- /dev/null +++ b/src/features/home/manager/api/schedule.ts @@ -0,0 +1,21 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + ManagerScheduleApiResponse, + ManagerScheduleQueryParams, +} from '@/features/home/manager/types/schedule' + +export async function fetchMonthlySchedules( + params: ManagerScheduleQueryParams +): Promise { + const response = await axiosInstance.get( + '/manager/schedules', + { + params: { + workspaceId: params.workspaceId, + year: params.year, + month: params.month, + }, + } + ) + return response.data +} diff --git a/src/features/home/manager/hooks/useManagerHomeViewModel.ts b/src/features/home/manager/hooks/useManagerHomeViewModel.ts index a5c9e33..578e08f 100644 --- a/src/features/home/manager/hooks/useManagerHomeViewModel.ts +++ b/src/features/home/manager/hooks/useManagerHomeViewModel.ts @@ -1,49 +1,17 @@ import { useEffect, useState } from 'react' import type { TodayWorkerItem } from '@/features/home/manager/ui/TodayWorkerList' -import type { CalendarViewData } from '@/features/home/user/schedule/types/schedule' import { useManagedWorkspacesQuery } from '@/features/home/manager/hooks/useManagedWorkspacesQuery' import { useWorkspaceDetailQuery } from '@/features/home/manager/hooks/useWorkspaceDetailQuery' import { useWorkspaceWorkersViewModel } from '@/features/home/manager/hooks/useWorkspaceWorkersViewModel' import { useManagedPostingsViewModel } from '@/features/home/manager/hooks/useManagedPostingsViewModel' import { useSubstituteRequestsViewModel } from '@/features/home/manager/hooks/useSubstituteRequestsViewModel' +import { useMonthlySchedulesViewModel } from '@/features/home/manager/hooks/useMonthlySchedulesViewModel' const TODAY_WORKERS: TodayWorkerItem[] = [ { id: '1', name: '알바생1', workTime: '00:00 ~ 00:00' }, { id: '2', name: '알바생2', workTime: '00:00 ~ 00:00' }, ] -const MANAGER_SCHEDULE_BASE_DATE = new Date('2026-01-08T00:00:00+09:00') -const MANAGER_SCHEDULE_SELECTED_DATE_KEY = '2026-01-08' -const MANAGER_SCHEDULE_ESTIMATED_EARNINGS_TEXT = '약 619,200원' - -const MANAGER_SCHEDULE_DAYS = [ - 1, 2, 3, 4, 9, 10, 11, 16, 17, 18, 23, 24, 25, 30, 31, -] - -const MANAGER_SCHEDULE_DATA: CalendarViewData = { - summary: { - totalWorkHours: 60, - eventCount: MANAGER_SCHEDULE_DAYS.length, - }, - events: MANAGER_SCHEDULE_DAYS.map((day, index) => { - const dayText = String(day).padStart(2, '0') - const dateKey = `2026-01-${dayText}` - - return { - shiftId: index + 1, - workspaceName: '가게 이름', - position: '알바', - status: 'CONFIRMED', - startDateTime: `${dateKey}T10:00:00+09:00`, - endDateTime: `${dateKey}T14:00:00+09:00`, - dateKey, - startTimeLabel: '10:00', - endTimeLabel: '14:00', - durationHours: 4, - } - }), -} - export function useManagerHomeViewModel() { const [isWorkspaceChangeModalOpen, setIsWorkspaceChangeModalOpen] = useState(false) @@ -74,6 +42,15 @@ export function useManagerHomeViewModel() { hasNextPage: hasMoreSubstitutes, } = useSubstituteRequestsViewModel(activeWorkspaceId) + const { + baseDate: scheduleBaseDate, + calendarData, + selectedDateKey, + isLoading: isScheduleLoading, + goToPrevMonth, + goToNextMonth, + } = useMonthlySchedulesViewModel(activeWorkspaceId) + useEffect(() => { if (!isWorkspaceChangeModalOpen) return @@ -116,10 +93,12 @@ export function useManagerHomeViewModel() { fetchMoreSubstitutes, hasMoreSubstitutes, schedule: { - baseDate: MANAGER_SCHEDULE_BASE_DATE, - selectedDateKey: MANAGER_SCHEDULE_SELECTED_DATE_KEY, - estimatedEarningsText: MANAGER_SCHEDULE_ESTIMATED_EARNINGS_TEXT, - data: MANAGER_SCHEDULE_DATA, + baseDate: scheduleBaseDate, + selectedDateKey, + data: calendarData, + isLoading: isScheduleLoading, + goToPrevMonth, + goToNextMonth, }, workspaceDetail, workspaceChangeModal: { diff --git a/src/features/home/manager/hooks/useMonthlySchedulesViewModel.ts b/src/features/home/manager/hooks/useMonthlySchedulesViewModel.ts new file mode 100644 index 0000000..ccb27ae --- /dev/null +++ b/src/features/home/manager/hooks/useMonthlySchedulesViewModel.ts @@ -0,0 +1,58 @@ +import { useCallback, useMemo, useState } from 'react' +import { addMonths, format } from 'date-fns' +import { useQuery } from '@tanstack/react-query' +import { fetchMonthlySchedules } from '@/features/home/manager/api/schedule' +import { adaptManagerScheduleResponse } from '@/features/home/manager/types/schedule' +import { queryKeys } from '@/shared/lib/queryKeys' + +const DATE_KEY_FORMAT = 'yyyy-MM-dd' + +export function useMonthlySchedulesViewModel(workspaceId: number | null) { + const [baseDate, setBaseDate] = useState(() => new Date()) + + const year = baseDate.getFullYear() + const month = baseDate.getMonth() + 1 + + const { data: rawData, isPending } = useQuery({ + queryKey: queryKeys.manager.schedules(workspaceId ?? 0, year, month), + queryFn: () => + fetchMonthlySchedules({ workspaceId: workspaceId!, year, month }), + enabled: workspaceId !== null, + }) + + const calendarData = useMemo( + () => (rawData ? adaptManagerScheduleResponse(rawData) : null), + [rawData] + ) + + // 선택된 날짜: 오늘이 현재 월이면 오늘, 아니면 해당 월 1일 + const selectedDateKey = useMemo(() => { + const today = new Date() + const todayKey = format(today, DATE_KEY_FORMAT) + const isSameMonth = + today.getFullYear() === year && today.getMonth() + 1 === month + return isSameMonth ? todayKey : format(baseDate, 'yyyy-MM-01') + }, [baseDate, year, month]) + + const onDateChange = useCallback((nextDate: Date) => { + setBaseDate(nextDate) + }, []) + + const goToPrevMonth = useCallback(() => { + setBaseDate(prev => addMonths(prev, -1)) + }, []) + + const goToNextMonth = useCallback(() => { + setBaseDate(prev => addMonths(prev, 1)) + }, []) + + return { + baseDate, + calendarData, + selectedDateKey, + isLoading: isPending && workspaceId !== null, + onDateChange, + goToPrevMonth, + goToNextMonth, + } +} diff --git a/src/features/home/manager/types/schedule.ts b/src/features/home/manager/types/schedule.ts new file mode 100644 index 0000000..c7a045f --- /dev/null +++ b/src/features/home/manager/types/schedule.ts @@ -0,0 +1,76 @@ +import type { CommonApiResponse } from '@/shared/types/common' +import type { StatusEnum } from '@/shared/types/enums' +import type { + CalendarEvent, + CalendarViewData, +} from '@/features/home/user/schedule/types/schedule' +import { + toDateKey, + toTimeLabel, + getDurationHours, +} from '@/features/home/user/schedule/lib/date' + +// ---- API DTOs ---- +export interface ManagerScheduleWorkspaceDto { + workspaceId: number + workspaceName: string +} + +export interface ManagerScheduleWorkerDto { + workerId: number + workerName: string +} + +export interface ManagerScheduleStatusDto { + value: string + description: string +} + +export interface ManagerScheduleDto { + shiftId: number + workspace: ManagerScheduleWorkspaceDto + assignedWorker: ManagerScheduleWorkerDto + startDateTime: string + endDateTime: string + position: string + status: ManagerScheduleStatusDto +} + +export type ManagerScheduleApiResponse = CommonApiResponse + +// ---- Query Params ---- +export interface ManagerScheduleQueryParams { + workspaceId: number + year: number + month: number +} + +// ---- Adapter ---- +function adaptManagerScheduleDto(dto: ManagerScheduleDto): CalendarEvent { + return { + shiftId: dto.shiftId, + workspaceName: dto.workspace.workspaceName, + position: dto.position, + status: dto.status.value as StatusEnum, + startDateTime: dto.startDateTime, + endDateTime: dto.endDateTime, + dateKey: toDateKey(dto.startDateTime), + startTimeLabel: toTimeLabel(dto.startDateTime), + endTimeLabel: toTimeLabel(dto.endDateTime), + durationHours: getDurationHours(dto.startDateTime, dto.endDateTime), + } +} + +export function adaptManagerScheduleResponse( + response: ManagerScheduleApiResponse +): CalendarViewData { + const events = response.data.map(adaptManagerScheduleDto) + const totalWorkHours = events.reduce((sum, e) => sum + e.durationHours, 0) + return { + summary: { + totalWorkHours, + eventCount: events.length, + }, + events, + } +} diff --git a/src/pages/manager/home/index.tsx b/src/pages/manager/home/index.tsx index f231156..57c3866 100644 --- a/src/pages/manager/home/index.tsx +++ b/src/pages/manager/home/index.tsx @@ -1,5 +1,8 @@ +import { format } from 'date-fns' +import { ko } from 'date-fns/locale' import { Navbar } from '@/shared/ui/common/Navbar' import { useNavigate } from 'react-router-dom' +import ChevronDownIcon from '@/assets/icons/home/chevron-down.svg?react' import { TodayWorkerList } from '@/features/home/manager/ui/TodayWorkerList' import { StoreWorkerListItem } from '@/features/home/manager/ui/StoreWorkerListItem' import { useManagerHomeViewModel } from '@/features/home/manager/hooks/useManagerHomeViewModel' @@ -133,7 +136,9 @@ export function ManagerHomePage() {
-
MM월 dd일
+
+ {format(new Date(), 'M월 d일', { locale: ko })} +
전체 보기
@@ -141,21 +146,34 @@ export function ManagerHomePage() {
-

- 우리 매장 시간표 -

- +

+ 우리 매장 시간표 +

+
+ + {format(schedule.baseDate, 'yyyy.MM')} + + + - } +
+
+
diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index 1131f41..d15f32b 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -45,4 +45,8 @@ export const queryKeys = { pageSize?: number }) => ['substitute', 'list', params] as const, }, + manager: { + schedules: (workspaceId: number, year: number, month: number) => + ['manager', 'schedules', workspaceId, year, month] as const, + }, } as const From e4910e6577f9ebd3136409b840fcae6efb0a9ddf Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Thu, 16 Apr 2026 20:36:14 +0900 Subject: [PATCH 25/39] =?UTF-8?q?refactor:=20=EC=BA=98=EB=A6=B0=EB=8D=94?= =?UTF-8?q?=20=EA=B3=B5=ED=86=B5=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/schedule/constants/calendar.ts | 29 ++++ .../hooks/useMonthlyCalendarViewModel.ts | 139 +++++++++++++++++ .../hooks/useMonthlyDateCellsState.ts | 30 ++++ src/features/home/common/schedule/lib/date.ts | 18 +++ .../common/schedule/types/calendarBase.ts | 7 + .../common/schedule/types/calendarView.ts | 24 +++ .../common/schedule/types/monthlyCalendar.ts | 40 +++++ .../common/schedule/ui/MonthlyCalendar.tsx | 108 ++++++++++++++ .../common/schedule/ui/MonthlyDateCell.tsx | 44 ++++++ .../common/schedule/ui/MonthlyDateGauge.tsx | 53 +++++++ src/features/home/index.ts | 6 +- src/features/home/manager/types/schedule.ts | 4 +- .../home/user/schedule/constants/calendar.ts | 40 ++--- .../hooks/useMonthlyCalendarViewModel.ts | 141 +----------------- .../hooks/useMonthlyDateCellsState.ts | 32 +--- src/features/home/user/schedule/lib/date.ts | 24 +-- .../home/user/schedule/types/calendar.ts | 9 +- .../user/schedule/types/monthlyCalendar.ts | 49 ++---- .../home/user/schedule/types/schedule.ts | 30 +--- .../home/user/schedule/ui/MonthlyCalendar.tsx | 110 +------------- .../home/user/schedule/ui/MonthlyDateCell.tsx | 46 +----- .../user/schedule/ui/MonthlyDateGauge.tsx | 55 +------ src/pages/manager/home/index.tsx | 50 ++----- 23 files changed, 555 insertions(+), 533 deletions(-) create mode 100644 src/features/home/common/schedule/constants/calendar.ts create mode 100644 src/features/home/common/schedule/hooks/useMonthlyCalendarViewModel.ts create mode 100644 src/features/home/common/schedule/hooks/useMonthlyDateCellsState.ts create mode 100644 src/features/home/common/schedule/lib/date.ts create mode 100644 src/features/home/common/schedule/types/calendarBase.ts create mode 100644 src/features/home/common/schedule/types/calendarView.ts create mode 100644 src/features/home/common/schedule/types/monthlyCalendar.ts create mode 100644 src/features/home/common/schedule/ui/MonthlyCalendar.tsx create mode 100644 src/features/home/common/schedule/ui/MonthlyDateCell.tsx create mode 100644 src/features/home/common/schedule/ui/MonthlyDateGauge.tsx diff --git a/src/features/home/common/schedule/constants/calendar.ts b/src/features/home/common/schedule/constants/calendar.ts new file mode 100644 index 0000000..6c4a0ae --- /dev/null +++ b/src/features/home/common/schedule/constants/calendar.ts @@ -0,0 +1,29 @@ +export const WEEKDAY_LABELS = [ + '일', + '월', + '화', + '수', + '목', + '금', + '토', +] as const + +/** 월요일 시작 주( date-fns weekStartsOn: 1 )와 그리드 열 순서를 맞춤 */ +export const WEEKDAY_LABELS_MONDAY_FIRST = [ + ...WEEKDAY_LABELS.slice(1), + WEEKDAY_LABELS[0], +] as const + +export const DATE_KEY_FORMAT = 'yyyy-MM-dd' +export const MONTH_LABEL_FORMAT = 'yyyy년 M월' + +export const DAILY_TIMELINE_HEIGHT = 322 +export const DAILY_TIMELINE_START_HOUR = 1 +export const DAILY_TIMELINE_END_HOUR = 8 + +export const DAILY_STATUS_STYLE_MAP: Record = { + PLANNED: 'bg-main-300/70', + CONFIRMED: 'bg-main/70', + CANCELLED: 'bg-bg-dark', + DELETED: 'bg-bg-dark/80', +} diff --git a/src/features/home/common/schedule/hooks/useMonthlyCalendarViewModel.ts b/src/features/home/common/schedule/hooks/useMonthlyCalendarViewModel.ts new file mode 100644 index 0000000..5c25968 --- /dev/null +++ b/src/features/home/common/schedule/hooks/useMonthlyCalendarViewModel.ts @@ -0,0 +1,139 @@ +import { + addDays, + eachDayOfInterval, + endOfMonth, + endOfWeek, + format, + isSameMonth, + startOfDay, + startOfMonth, + startOfWeek, +} from 'date-fns' +import { useMemo } from 'react' +import { + DATE_KEY_FORMAT, + MONTH_LABEL_FORMAT, + WEEKDAY_LABELS_MONDAY_FIRST, +} from '@/features/home/common/schedule/constants/calendar' +import { useMonthlyDateCellsState } from '@/features/home/common/schedule/hooks/useMonthlyDateCellsState' +import type { + MonthlyCalendarViewModel, + MonthlyCellInput, + MonthlyDayMetrics, + MonthlyCalendarPropsBase, +} from '@/features/home/common/schedule/types/monthlyCalendar' +import type { CalendarViewData } from '@/features/home/common/schedule/types/calendarView' + +function getMonthlyCells(baseDate: Date): MonthlyCellInput[] { + const monthStart = startOfMonth(baseDate) + const monthEnd = endOfMonth(baseDate) + const intervalStart = startOfWeek(monthStart, { weekStartsOn: 1 }) + const intervalEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }) + + return eachDayOfInterval({ start: intervalStart, end: intervalEnd }).map( + date => ({ + dateKey: format(date, DATE_KEY_FORMAT), + dayText: format(date, 'd'), + isCurrentMonth: isSameMonth(date, baseDate), + weekDay: date.getDay(), + }) + ) +} + +type MinuteRange = [number, number] + +function mergeMinuteRanges(ranges: MinuteRange[]) { + if (ranges.length === 0) return 0 + const sorted = [...ranges].sort((a, b) => a[0] - b[0]) + let [currentStart, currentEnd] = sorted[0] + let total = 0 + + for (let idx = 1; idx < sorted.length; idx += 1) { + const [nextStart, nextEnd] = sorted[idx] + if (nextStart <= currentEnd) { + currentEnd = Math.max(currentEnd, nextEnd) + continue + } + total += currentEnd - currentStart + currentStart = nextStart + currentEnd = nextEnd + } + + total += currentEnd - currentStart + return total +} + +function getDayMetricsByDate(data: CalendarViewData | null) { + const rangesByDate: Record = {} + + ;(data?.events ?? []).forEach(event => { + const start = new Date(event.startDateTime) + const end = new Date(event.endDateTime) + + if ( + Number.isNaN(start.getTime()) || + Number.isNaN(end.getTime()) || + end <= start + ) { + return + } + + let cursor = startOfDay(start) + const lastDay = startOfDay(end) + + while (cursor <= lastDay) { + const nextDay = addDays(cursor, 1) + const clipStart = Math.max(start.getTime(), cursor.getTime()) + const clipEnd = Math.min(end.getTime(), nextDay.getTime()) + + if (clipEnd > clipStart) { + const dayKey = format(cursor, DATE_KEY_FORMAT) + const startMinute = (clipStart - cursor.getTime()) / (1000 * 60) + const endMinute = (clipEnd - cursor.getTime()) / (1000 * 60) + rangesByDate[dayKey] = rangesByDate[dayKey] ?? [] + rangesByDate[dayKey].push([startMinute, endMinute]) + } + + cursor = nextDay + } + }) + + return Object.entries(rangesByDate).reduce>( + (acc, [dateKey, ranges]) => { + const occupiedMinutes = mergeMinuteRanges(ranges) + const dayHours = Number((occupiedMinutes / 60).toFixed(1)) + const dayProgress = Math.min(Math.max(occupiedMinutes / (24 * 60), 0), 1) + acc[dateKey] = { dayHours, dayProgress } + return acc + }, + {} + ) +} + +export function useMonthlyCalendarViewModel({ + baseDate, + data, + workspaceName, + selectedDateKey, +}: MonthlyCalendarPropsBase): MonthlyCalendarViewModel { + const cells = useMemo(() => getMonthlyCells(baseDate), [baseDate]) + const selectedKey = selectedDateKey ?? format(baseDate, DATE_KEY_FORMAT) + + const dayMetricsByDate = useMemo(() => getDayMetricsByDate(data), [data]) + + const monthlyDateCellsState = useMonthlyDateCellsState({ + cells, + dayMetricsByDate, + selectedKey, + }) + + return { + title: workspaceName ?? '월간 아르바이트', + monthLabel: format(baseDate, MONTH_LABEL_FORMAT), + totalWorkHoursText: String( + Math.round(data?.summary.totalWorkHours ?? 0) + ).padStart(2, '0'), + weekdayLabels: WEEKDAY_LABELS_MONDAY_FIRST, + monthlyDateCellsState, + } +} diff --git a/src/features/home/common/schedule/hooks/useMonthlyDateCellsState.ts b/src/features/home/common/schedule/hooks/useMonthlyDateCellsState.ts new file mode 100644 index 0000000..9cf16b9 --- /dev/null +++ b/src/features/home/common/schedule/hooks/useMonthlyDateCellsState.ts @@ -0,0 +1,30 @@ +import { useMemo } from 'react' +import type { UseMonthlyDateCellsStateParams } from '@/features/home/common/schedule/types/monthlyCalendar' + +export function useMonthlyDateCellsState({ + cells, + dayMetricsByDate, + selectedKey, +}: UseMonthlyDateCellsStateParams) { + return useMemo( + () => + cells.map(cell => { + const dayMetrics = dayMetricsByDate[cell.dateKey] + const dayHours = dayMetrics?.dayHours ?? 0 + const dayProgress = dayMetrics?.dayProgress ?? 0 + const isWeekend = cell.weekDay === 0 || cell.weekDay === 6 + const isSelected = cell.dateKey === selectedKey + const isActiveDay = dayHours > 0 && cell.isCurrentMonth + + return { + ...cell, + dayHours, + dayProgress, + isWeekend, + isSelected, + isActiveDay, + } + }), + [cells, dayMetricsByDate, selectedKey] + ) +} diff --git a/src/features/home/common/schedule/lib/date.ts b/src/features/home/common/schedule/lib/date.ts new file mode 100644 index 0000000..464737e --- /dev/null +++ b/src/features/home/common/schedule/lib/date.ts @@ -0,0 +1,18 @@ +const ISO_DATE_LENGTH = 10 +const ISO_TIME_START = 11 +const ISO_TIME_END = 16 + +export function toDateKey(iso: string) { + return iso.slice(0, ISO_DATE_LENGTH) +} + +export function toTimeLabel(iso: string) { + return iso.slice(ISO_TIME_START, ISO_TIME_END) +} + +export function getDurationHours(startIso: string, endIso: string) { + const start = new Date(startIso).getTime() + const end = new Date(endIso).getTime() + const diffHours = Math.max((end - start) / (1000 * 60 * 60), 0) + return Number(diffHours.toFixed(1)) +} diff --git a/src/features/home/common/schedule/types/calendarBase.ts b/src/features/home/common/schedule/types/calendarBase.ts new file mode 100644 index 0000000..53f0779 --- /dev/null +++ b/src/features/home/common/schedule/types/calendarBase.ts @@ -0,0 +1,7 @@ +import type { CalendarViewData } from '@/features/home/common/schedule/types/calendarView' + +export interface BaseCalendarProps { + baseDate: Date + data: CalendarViewData | null + workspaceName?: string +} diff --git a/src/features/home/common/schedule/types/calendarView.ts b/src/features/home/common/schedule/types/calendarView.ts new file mode 100644 index 0000000..54fd831 --- /dev/null +++ b/src/features/home/common/schedule/types/calendarView.ts @@ -0,0 +1,24 @@ +import type { StatusEnum } from '@/shared/types/enums' + +export interface CalendarEvent { + shiftId: number + workspaceName: string + position: string + status: StatusEnum + startDateTime: string + endDateTime: string + dateKey: string + startTimeLabel: string + endTimeLabel: string + durationHours: number +} + +export interface CalendarSummary { + totalWorkHours: number + eventCount: number +} + +export interface CalendarViewData { + summary: CalendarSummary + events: CalendarEvent[] +} diff --git a/src/features/home/common/schedule/types/monthlyCalendar.ts b/src/features/home/common/schedule/types/monthlyCalendar.ts new file mode 100644 index 0000000..f721a4a --- /dev/null +++ b/src/features/home/common/schedule/types/monthlyCalendar.ts @@ -0,0 +1,40 @@ +import type { WEEKDAY_LABELS_MONDAY_FIRST } from '@/features/home/common/schedule/constants/calendar' +import type { BaseCalendarProps } from '@/features/home/common/schedule/types/calendarBase' + +export interface MonthlyCellInput { + dateKey: string + dayText: string + isCurrentMonth: boolean + weekDay: number +} + +export interface MonthlyDateCellState extends MonthlyCellInput { + dayHours: number + dayProgress: number + isWeekend: boolean + isSelected: boolean + isActiveDay: boolean +} + +export interface MonthlyDayMetrics { + dayHours: number + dayProgress: number +} + +export interface UseMonthlyDateCellsStateParams { + cells: MonthlyCellInput[] + dayMetricsByDate: Record + selectedKey: string +} + +export interface MonthlyCalendarViewModel { + title: string + monthLabel: string + totalWorkHoursText: string + weekdayLabels: typeof WEEKDAY_LABELS_MONDAY_FIRST + monthlyDateCellsState: MonthlyDateCellState[] +} + +export interface MonthlyCalendarPropsBase extends BaseCalendarProps { + selectedDateKey?: string +} diff --git a/src/features/home/common/schedule/ui/MonthlyCalendar.tsx b/src/features/home/common/schedule/ui/MonthlyCalendar.tsx new file mode 100644 index 0000000..9a0acbd --- /dev/null +++ b/src/features/home/common/schedule/ui/MonthlyCalendar.tsx @@ -0,0 +1,108 @@ +import type { ReactNode } from 'react' +import DownIcon from '@/assets/icons/home/chevron-down.svg?react' +import { useMonthlyCalendarViewModel } from '@/features/home/common/schedule/hooks/useMonthlyCalendarViewModel' +import type { MonthlyCalendarPropsBase } from '@/features/home/common/schedule/types/monthlyCalendar' +import { MonthlyDateCell } from '@/features/home/common/schedule/ui/MonthlyDateCell' + +interface MonthlyCalendarProps extends MonthlyCalendarPropsBase { + isLoading?: boolean + hideTitle?: boolean + rightAction?: ReactNode + estimatedEarningsText?: string + layout?: 'default' | 'manager' +} + +export function MonthlyCalendar({ + baseDate, + data, + workspaceName, + isLoading = false, + selectedDateKey, + hideTitle = false, + rightAction, + estimatedEarningsText, + layout = 'default', +}: MonthlyCalendarProps) { + const { + title, + monthLabel, + totalWorkHoursText, + weekdayLabels, + monthlyDateCellsState, + } = useMonthlyCalendarViewModel({ + baseDate, + data, + workspaceName, + selectedDateKey, + }) + + if (isLoading) { + return ( +
+

월간 일정을 불러오는 중...

+
+ ) + } + + return ( +
+
+ {!hideTitle &&

{title}

} + +
+ + {rightAction} +
+ +
+
+ {totalWorkHoursText} + 시간 근무해요 +
+ {estimatedEarningsText ? ( + + {estimatedEarningsText} + + ) : null} +
+
+ +
+
+ {weekdayLabels.map((label, index) => ( + + {label} + + ))} +
+ +
+ {monthlyDateCellsState.map(cell => { + return ( + + ) + })} +
+
+
+ ) +} diff --git a/src/features/home/common/schedule/ui/MonthlyDateCell.tsx b/src/features/home/common/schedule/ui/MonthlyDateCell.tsx new file mode 100644 index 0000000..3f69e70 --- /dev/null +++ b/src/features/home/common/schedule/ui/MonthlyDateCell.tsx @@ -0,0 +1,44 @@ +import { MonthlyDateGauge } from '@/features/home/common/schedule/ui/MonthlyDateGauge' + +interface MonthlyDateCellProps { + dayText: string + isCurrentMonth: boolean + isWeekend: boolean + isSelected: boolean + isActiveDay: boolean + gaugeRatio?: number +} + +export function MonthlyDateCell({ + dayText, + isCurrentMonth, + isWeekend, + isSelected, + isActiveDay, + gaugeRatio = 0, +}: MonthlyDateCellProps) { + const dayTextColor = !isCurrentMonth + ? 'text-text-50' + : isWeekend + ? 'text-[#DC0000]' + : 'text-text-50' + + return ( +
+ {isActiveDay ? ( +
+ + + {dayText} + +
+ ) : ( +

{dayText}

+ )} +
+ ) +} diff --git a/src/features/home/common/schedule/ui/MonthlyDateGauge.tsx b/src/features/home/common/schedule/ui/MonthlyDateGauge.tsx new file mode 100644 index 0000000..5c86200 --- /dev/null +++ b/src/features/home/common/schedule/ui/MonthlyDateGauge.tsx @@ -0,0 +1,53 @@ +interface MonthlyDateGaugeProps { + gaugeRatio: number + size?: number + strokeWidth?: number + backgroundStroke?: string + progressStroke?: string +} + +export function MonthlyDateGauge({ + gaugeRatio, + size = 32, + strokeWidth = 4, + backgroundStroke = '#EFEFEF', + progressStroke = '#2CE283', +}: MonthlyDateGaugeProps) { + const normalizedRatio = Math.min(Math.max(gaugeRatio, 0), 1) + const center = size / 2 + const radius = (size - strokeWidth) / 2 + const circumference = 2 * Math.PI * radius + const progressLength = circumference * normalizedRatio + + return ( + + ) +} diff --git a/src/features/home/index.ts b/src/features/home/index.ts index 837371d..8caf520 100644 --- a/src/features/home/index.ts +++ b/src/features/home/index.ts @@ -8,10 +8,8 @@ export { AppliedStoreList } from '@/features/home/user/applied-stores/ui/Applied export { AppliedStoreDetailModal } from '@/features/home/user/applied-stores/ui/AppliedStoreDetailModal' export { WorkingStoresList } from '@/features/home/user/workspace/ui/WorkingStoresList' export { WorkingStoreCard } from '@/features/home/user/workspace/ui/WorkingStoreCard' -export type { - HomeCalendarMode, - CalendarViewData, -} from '@/features/home/user/schedule/types/schedule' +export type { HomeCalendarMode } from '@/features/home/user/schedule/types/schedule' +export type { CalendarViewData } from '@/features/home/common/schedule/types/calendarView' export { getSelfSchedule, adaptScheduleResponse, diff --git a/src/features/home/manager/types/schedule.ts b/src/features/home/manager/types/schedule.ts index c7a045f..1b47c88 100644 --- a/src/features/home/manager/types/schedule.ts +++ b/src/features/home/manager/types/schedule.ts @@ -3,12 +3,12 @@ import type { StatusEnum } from '@/shared/types/enums' import type { CalendarEvent, CalendarViewData, -} from '@/features/home/user/schedule/types/schedule' +} from '@/features/home/common/schedule/types/calendarView' import { toDateKey, toTimeLabel, getDurationHours, -} from '@/features/home/user/schedule/lib/date' +} from '@/features/home/common/schedule/lib/date' // ---- API DTOs ---- export interface ManagerScheduleWorkspaceDto { diff --git a/src/features/home/user/schedule/constants/calendar.ts b/src/features/home/user/schedule/constants/calendar.ts index 6c4a0ae..5678e0b 100644 --- a/src/features/home/user/schedule/constants/calendar.ts +++ b/src/features/home/user/schedule/constants/calendar.ts @@ -1,29 +1,11 @@ -export const WEEKDAY_LABELS = [ - '일', - '월', - '화', - '수', - '목', - '금', - '토', -] as const - -/** 월요일 시작 주( date-fns weekStartsOn: 1 )와 그리드 열 순서를 맞춤 */ -export const WEEKDAY_LABELS_MONDAY_FIRST = [ - ...WEEKDAY_LABELS.slice(1), - WEEKDAY_LABELS[0], -] as const - -export const DATE_KEY_FORMAT = 'yyyy-MM-dd' -export const MONTH_LABEL_FORMAT = 'yyyy년 M월' - -export const DAILY_TIMELINE_HEIGHT = 322 -export const DAILY_TIMELINE_START_HOUR = 1 -export const DAILY_TIMELINE_END_HOUR = 8 - -export const DAILY_STATUS_STYLE_MAP: Record = { - PLANNED: 'bg-main-300/70', - CONFIRMED: 'bg-main/70', - CANCELLED: 'bg-bg-dark', - DELETED: 'bg-bg-dark/80', -} +// constants는 common으로 이동 — 하위 호환 re-export +export { + WEEKDAY_LABELS, + WEEKDAY_LABELS_MONDAY_FIRST, + DATE_KEY_FORMAT, + MONTH_LABEL_FORMAT, + DAILY_TIMELINE_HEIGHT, + DAILY_TIMELINE_START_HOUR, + DAILY_TIMELINE_END_HOUR, + DAILY_STATUS_STYLE_MAP, +} from '@/features/home/common/schedule/constants/calendar' diff --git a/src/features/home/user/schedule/hooks/useMonthlyCalendarViewModel.ts b/src/features/home/user/schedule/hooks/useMonthlyCalendarViewModel.ts index 05b55f3..e41c322 100644 --- a/src/features/home/user/schedule/hooks/useMonthlyCalendarViewModel.ts +++ b/src/features/home/user/schedule/hooks/useMonthlyCalendarViewModel.ts @@ -1,139 +1,2 @@ -import { - addDays, - eachDayOfInterval, - endOfMonth, - endOfWeek, - format, - isSameMonth, - startOfDay, - startOfMonth, - startOfWeek, -} from 'date-fns' -import { useMemo } from 'react' -import { - DATE_KEY_FORMAT, - MONTH_LABEL_FORMAT, - WEEKDAY_LABELS_MONDAY_FIRST, -} from '@/features/home/user/schedule/constants/calendar' -import { useMonthlyDateCellsState } from '@/features/home/user/schedule/hooks/useMonthlyDateCellsState' -import type { - MonthlyCalendarViewModel, - MonthlyCellInput, - MonthlyDayMetrics, - MonthlyCalendarPropsBase, -} from '@/features/home/user/schedule/types/monthlyCalendar' -import type { CalendarViewData } from '@/features/home/user/schedule/types/schedule' - -function getMonthlyCells(baseDate: Date): MonthlyCellInput[] { - const monthStart = startOfMonth(baseDate) - const monthEnd = endOfMonth(baseDate) - const intervalStart = startOfWeek(monthStart, { weekStartsOn: 1 }) - const intervalEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }) - - return eachDayOfInterval({ start: intervalStart, end: intervalEnd }).map( - date => ({ - dateKey: format(date, DATE_KEY_FORMAT), - dayText: format(date, 'd'), - isCurrentMonth: isSameMonth(date, baseDate), - weekDay: date.getDay(), - }) - ) -} - -type MinuteRange = [number, number] - -function mergeMinuteRanges(ranges: MinuteRange[]) { - if (ranges.length === 0) return 0 - const sorted = [...ranges].sort((a, b) => a[0] - b[0]) - let [currentStart, currentEnd] = sorted[0] - let total = 0 - - for (let idx = 1; idx < sorted.length; idx += 1) { - const [nextStart, nextEnd] = sorted[idx] - if (nextStart <= currentEnd) { - currentEnd = Math.max(currentEnd, nextEnd) - continue - } - total += currentEnd - currentStart - currentStart = nextStart - currentEnd = nextEnd - } - - total += currentEnd - currentStart - return total -} - -function getDayMetricsByDate(data: CalendarViewData | null) { - const rangesByDate: Record = {} - - ;(data?.events ?? []).forEach(event => { - const start = new Date(event.startDateTime) - const end = new Date(event.endDateTime) - - if ( - Number.isNaN(start.getTime()) || - Number.isNaN(end.getTime()) || - end <= start - ) { - return - } - - let cursor = startOfDay(start) - const lastDay = startOfDay(end) - - while (cursor <= lastDay) { - const nextDay = addDays(cursor, 1) - const clipStart = Math.max(start.getTime(), cursor.getTime()) - const clipEnd = Math.min(end.getTime(), nextDay.getTime()) - - if (clipEnd > clipStart) { - const dayKey = format(cursor, DATE_KEY_FORMAT) - const startMinute = (clipStart - cursor.getTime()) / (1000 * 60) - const endMinute = (clipEnd - cursor.getTime()) / (1000 * 60) - rangesByDate[dayKey] = rangesByDate[dayKey] ?? [] - rangesByDate[dayKey].push([startMinute, endMinute]) - } - - cursor = nextDay - } - }) - - return Object.entries(rangesByDate).reduce>( - (acc, [dateKey, ranges]) => { - const occupiedMinutes = mergeMinuteRanges(ranges) - const dayHours = Number((occupiedMinutes / 60).toFixed(1)) - const dayProgress = Math.min(Math.max(occupiedMinutes / (24 * 60), 0), 1) - acc[dateKey] = { dayHours, dayProgress } - return acc - }, - {} - ) -} - -export function useMonthlyCalendarViewModel({ - baseDate, - data, - workspaceName, - selectedDateKey, -}: MonthlyCalendarPropsBase): MonthlyCalendarViewModel { - const cells = useMemo(() => getMonthlyCells(baseDate), [baseDate]) - const selectedKey = selectedDateKey ?? format(baseDate, DATE_KEY_FORMAT) - - const dayMetricsByDate = useMemo(() => getDayMetricsByDate(data), [data]) - - const monthlyDateCellsState = useMonthlyDateCellsState({ - cells, - dayMetricsByDate, - selectedKey, - }) - - return { - title: workspaceName ?? '월간 아르바이트', - monthLabel: format(baseDate, MONTH_LABEL_FORMAT), - totalWorkHoursText: String( - Math.round(data?.summary.totalWorkHours ?? 0) - ).padStart(2, '0'), - weekdayLabels: WEEKDAY_LABELS_MONDAY_FIRST, - monthlyDateCellsState, - } -} +// useMonthlyCalendarViewModel는 common으로 이동 — 하위 호환 re-export +export { useMonthlyCalendarViewModel } from '@/features/home/common/schedule/hooks/useMonthlyCalendarViewModel' diff --git a/src/features/home/user/schedule/hooks/useMonthlyDateCellsState.ts b/src/features/home/user/schedule/hooks/useMonthlyDateCellsState.ts index 7355b4d..156204e 100644 --- a/src/features/home/user/schedule/hooks/useMonthlyDateCellsState.ts +++ b/src/features/home/user/schedule/hooks/useMonthlyDateCellsState.ts @@ -1,30 +1,2 @@ -import { useMemo } from 'react' -import type { UseMonthlyDateCellsStateParams } from '@/features/home/user/schedule/types/monthlyCalendar' - -export function useMonthlyDateCellsState({ - cells, - dayMetricsByDate, - selectedKey, -}: UseMonthlyDateCellsStateParams) { - return useMemo( - () => - cells.map(cell => { - const dayMetrics = dayMetricsByDate[cell.dateKey] - const dayHours = dayMetrics?.dayHours ?? 0 - const dayProgress = dayMetrics?.dayProgress ?? 0 - const isWeekend = cell.weekDay === 0 || cell.weekDay === 6 - const isSelected = cell.dateKey === selectedKey - const isActiveDay = dayHours > 0 && cell.isCurrentMonth - - return { - ...cell, - dayHours, - dayProgress, - isWeekend, - isSelected, - isActiveDay, - } - }), - [cells, dayMetricsByDate, selectedKey] - ) -} +// useMonthlyDateCellsState는 common으로 이동 — 하위 호환 re-export +export { useMonthlyDateCellsState } from '@/features/home/common/schedule/hooks/useMonthlyDateCellsState' diff --git a/src/features/home/user/schedule/lib/date.ts b/src/features/home/user/schedule/lib/date.ts index 7c5f79f..65f6e66 100644 --- a/src/features/home/user/schedule/lib/date.ts +++ b/src/features/home/user/schedule/lib/date.ts @@ -16,24 +16,12 @@ import type { SelfScheduleQueryParams } from '@/features/home/user/schedule/api/ import type { ScheduleListItem } from '@/features/home/user/schedule/types/scheduleList' import { WEEKDAY_LABELS } from '@/features/home/user/schedule/constants/calendar' -const ISO_DATE_LENGTH = 10 -const ISO_TIME_START = 11 -const ISO_TIME_END = 16 - -export function toDateKey(iso: string) { - return iso.slice(0, ISO_DATE_LENGTH) -} - -export function toTimeLabel(iso: string) { - return iso.slice(ISO_TIME_START, ISO_TIME_END) -} - -export function getDurationHours(startIso: string, endIso: string) { - const start = new Date(startIso).getTime() - const end = new Date(endIso).getTime() - const diffHours = Math.max((end - start) / (1000 * 60 * 60), 0) - return Number(diffHours.toFixed(1)) -} +// 순수 날짜 유틸은 common으로 이동 — 하위 호환 re-export +export { + toDateKey, + toTimeLabel, + getDurationHours, +} from '@/features/home/common/schedule/lib/date' export function getMonthlyDateCells(baseDate: Date) { const monthStart = startOfMonth(baseDate) diff --git a/src/features/home/user/schedule/types/calendar.ts b/src/features/home/user/schedule/types/calendar.ts index d1826bd..c7485a5 100644 --- a/src/features/home/user/schedule/types/calendar.ts +++ b/src/features/home/user/schedule/types/calendar.ts @@ -1,7 +1,2 @@ -import type { CalendarViewData } from '@/features/home/user/schedule/types/schedule' - -export interface BaseCalendarProps { - baseDate: Date - data: CalendarViewData | null - workspaceName?: string -} +// BaseCalendarProps는 common으로 이동 — 하위 호환 re-export +export type { BaseCalendarProps } from '@/features/home/common/schedule/types/calendarBase' diff --git a/src/features/home/user/schedule/types/monthlyCalendar.ts b/src/features/home/user/schedule/types/monthlyCalendar.ts index f509867..c01c15b 100644 --- a/src/features/home/user/schedule/types/monthlyCalendar.ts +++ b/src/features/home/user/schedule/types/monthlyCalendar.ts @@ -1,40 +1,9 @@ -import type { WEEKDAY_LABELS_MONDAY_FIRST } from '@/features/home/user/schedule/constants/calendar' -import type { BaseCalendarProps } from '@/features/home/user/schedule/types/calendar' - -export interface MonthlyCellInput { - dateKey: string - dayText: string - isCurrentMonth: boolean - weekDay: number -} - -export interface MonthlyDateCellState extends MonthlyCellInput { - dayHours: number - dayProgress: number - isWeekend: boolean - isSelected: boolean - isActiveDay: boolean -} - -export interface MonthlyDayMetrics { - dayHours: number - dayProgress: number -} - -export interface UseMonthlyDateCellsStateParams { - cells: MonthlyCellInput[] - dayMetricsByDate: Record - selectedKey: string -} - -export interface MonthlyCalendarViewModel { - title: string - monthLabel: string - totalWorkHoursText: string - weekdayLabels: typeof WEEKDAY_LABELS_MONDAY_FIRST - monthlyDateCellsState: MonthlyDateCellState[] -} - -export interface MonthlyCalendarPropsBase extends BaseCalendarProps { - selectedDateKey?: string -} +// monthlyCalendar 타입들은 common으로 이동 — 하위 호환 re-export +export type { + MonthlyCellInput, + MonthlyDateCellState, + MonthlyDayMetrics, + UseMonthlyDateCellsStateParams, + MonthlyCalendarViewModel, + MonthlyCalendarPropsBase, +} from '@/features/home/common/schedule/types/monthlyCalendar' diff --git a/src/features/home/user/schedule/types/schedule.ts b/src/features/home/user/schedule/types/schedule.ts index 8baa8d4..e79cbc2 100644 --- a/src/features/home/user/schedule/types/schedule.ts +++ b/src/features/home/user/schedule/types/schedule.ts @@ -1,6 +1,13 @@ import type { CommonApiResponse } from '@/shared/types/common' import type { StatusEnum } from '@/shared/types/enums' +// CalendarEvent, CalendarSummary, CalendarViewData는 common으로 이동 — 하위 호환 re-export +export type { + CalendarEvent, + CalendarSummary, + CalendarViewData, +} from '@/features/home/common/schedule/types/calendarView' + export type HomeCalendarMode = 'monthly' | 'weekly' | 'daily' export interface WorkspaceInfo { @@ -23,26 +30,3 @@ export interface ScheduleDataDto { } export type ScheduleApiResponse = CommonApiResponse - -export interface CalendarEvent { - shiftId: number - workspaceName: string - position: string - status: StatusEnum - startDateTime: string - endDateTime: string - dateKey: string - startTimeLabel: string - endTimeLabel: string - durationHours: number -} - -export interface CalendarSummary { - totalWorkHours: number - eventCount: number -} - -export interface CalendarViewData { - summary: CalendarSummary - events: CalendarEvent[] -} diff --git a/src/features/home/user/schedule/ui/MonthlyCalendar.tsx b/src/features/home/user/schedule/ui/MonthlyCalendar.tsx index bc10f85..db4f01c 100644 --- a/src/features/home/user/schedule/ui/MonthlyCalendar.tsx +++ b/src/features/home/user/schedule/ui/MonthlyCalendar.tsx @@ -1,108 +1,2 @@ -import type { ReactNode } from 'react' -import DownIcon from '@/assets/icons/home/chevron-down.svg?react' -import { useMonthlyCalendarViewModel } from '@/features/home/user/schedule/hooks/useMonthlyCalendarViewModel' -import type { MonthlyCalendarPropsBase } from '@/features/home/user/schedule/types/monthlyCalendar' -import { MonthlyDateCell } from '@/features/home/user/schedule/ui/MonthlyDateCell' - -interface MonthlyCalendarProps extends MonthlyCalendarPropsBase { - isLoading?: boolean - hideTitle?: boolean - rightAction?: ReactNode - estimatedEarningsText?: string - layout?: 'default' | 'manager' -} - -export function MonthlyCalendar({ - baseDate, - data, - workspaceName, - isLoading = false, - selectedDateKey, - hideTitle = false, - rightAction, - estimatedEarningsText, - layout = 'default', -}: MonthlyCalendarProps) { - const { - title, - monthLabel, - totalWorkHoursText, - weekdayLabels, - monthlyDateCellsState, - } = useMonthlyCalendarViewModel({ - baseDate, - data, - workspaceName, - selectedDateKey, - }) - - if (isLoading) { - return ( -
-

월간 일정을 불러오는 중...

-
- ) - } - - return ( -
-
- {!hideTitle &&

{title}

} - -
- - {rightAction} -
- -
-
- {totalWorkHoursText} - 시간 근무해요 -
- {estimatedEarningsText ? ( - - {estimatedEarningsText} - - ) : null} -
-
- -
-
- {weekdayLabels.map((label, index) => ( - - {label} - - ))} -
- -
- {monthlyDateCellsState.map(cell => { - return ( - - ) - })} -
-
-
- ) -} +// MonthlyCalendar는 common으로 이동 — 하위 호환 re-export +export { MonthlyCalendar } from '@/features/home/common/schedule/ui/MonthlyCalendar' diff --git a/src/features/home/user/schedule/ui/MonthlyDateCell.tsx b/src/features/home/user/schedule/ui/MonthlyDateCell.tsx index 046c98b..d71512c 100644 --- a/src/features/home/user/schedule/ui/MonthlyDateCell.tsx +++ b/src/features/home/user/schedule/ui/MonthlyDateCell.tsx @@ -1,44 +1,2 @@ -import { MonthlyDateGauge } from '@/features/home/user/schedule/ui/MonthlyDateGauge' - -interface MonthlyDateCellProps { - dayText: string - isCurrentMonth: boolean - isWeekend: boolean - isSelected: boolean - isActiveDay: boolean - gaugeRatio?: number -} - -export function MonthlyDateCell({ - dayText, - isCurrentMonth, - isWeekend, - isSelected, - isActiveDay, - gaugeRatio = 0, -}: MonthlyDateCellProps) { - const dayTextColor = !isCurrentMonth - ? 'text-text-50' - : isWeekend - ? 'text-[#DC0000]' - : 'text-text-50' - - return ( -
- {isActiveDay ? ( -
- - - {dayText} - -
- ) : ( -

{dayText}

- )} -
- ) -} +// MonthlyDateCell는 common으로 이동 — 하위 호환 re-export +export { MonthlyDateCell } from '@/features/home/common/schedule/ui/MonthlyDateCell' diff --git a/src/features/home/user/schedule/ui/MonthlyDateGauge.tsx b/src/features/home/user/schedule/ui/MonthlyDateGauge.tsx index 5c86200..028a74c 100644 --- a/src/features/home/user/schedule/ui/MonthlyDateGauge.tsx +++ b/src/features/home/user/schedule/ui/MonthlyDateGauge.tsx @@ -1,53 +1,2 @@ -interface MonthlyDateGaugeProps { - gaugeRatio: number - size?: number - strokeWidth?: number - backgroundStroke?: string - progressStroke?: string -} - -export function MonthlyDateGauge({ - gaugeRatio, - size = 32, - strokeWidth = 4, - backgroundStroke = '#EFEFEF', - progressStroke = '#2CE283', -}: MonthlyDateGaugeProps) { - const normalizedRatio = Math.min(Math.max(gaugeRatio, 0), 1) - const center = size / 2 - const radius = (size - strokeWidth) / 2 - const circumference = 2 * Math.PI * radius - const progressLength = circumference * normalizedRatio - - return ( - - ) -} +// MonthlyDateGauge는 common으로 이동 — 하위 호환 re-export +export { MonthlyDateGauge } from '@/features/home/common/schedule/ui/MonthlyDateGauge' diff --git a/src/pages/manager/home/index.tsx b/src/pages/manager/home/index.tsx index 57c3866..fb007d9 100644 --- a/src/pages/manager/home/index.tsx +++ b/src/pages/manager/home/index.tsx @@ -2,12 +2,11 @@ import { format } from 'date-fns' import { ko } from 'date-fns/locale' import { Navbar } from '@/shared/ui/common/Navbar' import { useNavigate } from 'react-router-dom' -import ChevronDownIcon from '@/assets/icons/home/chevron-down.svg?react' import { TodayWorkerList } from '@/features/home/manager/ui/TodayWorkerList' import { StoreWorkerListItem } from '@/features/home/manager/ui/StoreWorkerListItem' import { useManagerHomeViewModel } from '@/features/home/manager/hooks/useManagerHomeViewModel' import { WorkspaceChangeList } from '@/features/home/manager/ui/WorkspaceChangeList' -import { MonthlyCalendar } from '@/features/home/user/schedule/ui/MonthlyCalendar' +import { MonthlyCalendar } from '@/features/home/common/schedule/ui/MonthlyCalendar' import { OngoingPostingCard } from '@/shared/ui/manager/OngoingPostingCard' import { SubstituteApprovalCard } from '@/shared/ui/manager/SubstituteApprovalCard' import { MoreButton } from '@/shared/ui/common/MoreButton' @@ -146,34 +145,21 @@ export function ManagerHomePage() {
-
-

- 우리 매장 시간표 -

-
- - - {format(schedule.baseDate, 'yyyy.MM')} - - +

+ 우리 매장 시간표 +

+ navigate('/manager/worker-schedule')} > -
-
-
From 7bac714085bcb5d02d2adb5500bdc34ffbb70163 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Thu, 16 Apr 2026 20:45:09 +0900 Subject: [PATCH 26/39] =?UTF-8?q?fix:=20date=20=EC=9C=A0=ED=8B=B8=20re-exp?= =?UTF-8?q?ort=20=EB=88=84=EB=9D=BD=20=EB=B0=8F=20useWorkspaceMembers=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/user/schedule/lib/date.ts | 7 ++++--- .../user/workspace-members/hooks/useWorkspaceMembers.ts | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/features/home/user/schedule/lib/date.ts b/src/features/home/user/schedule/lib/date.ts index 65f6e66..00b97ca 100644 --- a/src/features/home/user/schedule/lib/date.ts +++ b/src/features/home/user/schedule/lib/date.ts @@ -15,14 +15,15 @@ import type { HomeCalendarMode } from '@/features/home/user/schedule/types/sched import type { SelfScheduleQueryParams } from '@/features/home/user/schedule/api/schedule' import type { ScheduleListItem } from '@/features/home/user/schedule/types/scheduleList' import { WEEKDAY_LABELS } from '@/features/home/user/schedule/constants/calendar' - -// 순수 날짜 유틸은 common으로 이동 — 하위 호환 re-export -export { +import { toDateKey, toTimeLabel, getDurationHours, } from '@/features/home/common/schedule/lib/date' +// 순수 날짜 유틸은 common으로 이동 — 하위 호환 re-export +export { toDateKey, toTimeLabel, getDurationHours } + export function getMonthlyDateCells(baseDate: Date) { const monthStart = startOfMonth(baseDate) const monthEnd = endOfMonth(baseDate) diff --git a/src/pages/user/workspace-members/hooks/useWorkspaceMembers.ts b/src/pages/user/workspace-members/hooks/useWorkspaceMembers.ts index bf067ae..559d72d 100644 --- a/src/pages/user/workspace-members/hooks/useWorkspaceMembers.ts +++ b/src/pages/user/workspace-members/hooks/useWorkspaceMembers.ts @@ -5,6 +5,7 @@ import { useWorkspaceManagersViewModel } from '@/features/home/user/workspace/ho type Params = { workspaceId?: string initialPageSize?: number + loadMorePageSize?: number } export function useWorkspaceMembers(params: Params) { From 8649475b938ef17418dfc708a98e495f710b42b6 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Fri, 17 Apr 2026 17:23:37 +0900 Subject: [PATCH 27/39] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=B4=ED=9B=84=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/login/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index 3f55cfb..fc45231 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -20,9 +20,9 @@ export function LoginPage() { useEffect(() => { if (isLoggedIn && token) { if (scope === 'MANAGER') { - navigate('/main', { replace: true }) + navigate('/manager/home', { replace: true }) } else { - navigate('/user/job-lookup-map', { replace: true }) + navigate('/user/home', { replace: true }) } } }, [isLoggedIn, scope, token, navigate]) From 131b1287d005642d185e85bfc485f6582371b190 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Fri, 17 Apr 2026 18:18:16 +0900 Subject: [PATCH 28/39] =?UTF-8?q?fix:=20=EC=A7=80=EC=9B=90=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20API=20=EC=9D=91=EB=8B=B5=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/applied-stores/hooks/useAppliedStoresViewModel.ts | 4 ++-- src/features/home/user/applied-stores/types/application.ts | 5 ++--- src/shared/ui/common/Docbar.tsx | 4 +++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts b/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts index e5ace31..f3eb64d 100644 --- a/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts +++ b/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts @@ -66,12 +66,12 @@ export function useAppliedStoresViewModel() { status: apiStatus.length ? apiStatus : undefined, }), initialPageParam: undefined as string | undefined, - getNextPageParam: lastPage => lastPage.data.page.cursor ?? undefined, + getNextPageParam: lastPage => lastPage.page.cursor ?? undefined, }) const stores = useMemo( () => - data?.pages.flatMap(page => page.data.data.map(adaptApplicationDto)) ?? + data?.pages.flatMap(page => page.data.map(adaptApplicationDto)) ?? [], [data] ) diff --git a/src/features/home/user/applied-stores/types/application.ts b/src/features/home/user/applied-stores/types/application.ts index f226fe5..d4dc01e 100644 --- a/src/features/home/user/applied-stores/types/application.ts +++ b/src/features/home/user/applied-stores/types/application.ts @@ -1,4 +1,3 @@ -import type { CommonApiResponse } from '@/shared/types/common' import type { AppliedStoreData, ApplicationStatus, @@ -48,10 +47,10 @@ export interface ApplicationPageDto { totalCount: number } -export type ApplicationListApiResponse = CommonApiResponse<{ +export type ApplicationListApiResponse = { page: ApplicationPageDto data: ApplicationDto[] -}> +} // ---- Query Params ---- export interface ApplicationListQueryParams { diff --git a/src/shared/ui/common/Docbar.tsx b/src/shared/ui/common/Docbar.tsx index b1f3b52..9226f90 100644 --- a/src/shared/ui/common/Docbar.tsx +++ b/src/shared/ui/common/Docbar.tsx @@ -9,6 +9,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { useDocStore } from '@/shared/stores/useDocStore' import { typography } from '@/shared/lib/tokens' import { TAB_TITLE_MAP, type TabKey } from '@/shared/types/tab' +import useAuthStore from '@/shared/stores/useAuthStore' function DocContent({ icon, @@ -108,13 +109,14 @@ export function Docbar() { const setSelectedTabByPathname = useDocStore( state => state.setSelectedTabByPathname ) + const { scope } = useAuthStore( ) useEffect(() => { setSelectedTabByPathname(pathname) }, [pathname, setSelectedTabByPathname]) const pathByTab: Record = { - home: '/manager/home', + home: scope === 'MANAGER' ? '/manager/home' : '/user/home', search: '/user/job-lookup-map', message: '/message', repute: '/repute', From 383d68ff798d707bc734a772e7a144fff5375ac8 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Fri, 17 Apr 2026 20:27:30 +0900 Subject: [PATCH 29/39] =?UTF-8?q?fix:=20=EC=BB=AC=EB=9F=AC=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/common/schedule/ui/MonthlyCalendar.tsx | 2 +- src/features/home/common/schedule/ui/MonthlyDateCell.tsx | 2 +- .../user/applied-stores/hooks/useAppliedStoresViewModel.ts | 4 +--- src/pages/login/index.tsx | 4 ++-- src/pages/signup/components/EmailVerification.tsx | 6 +++--- src/pages/signup/components/PhoneVerification.tsx | 6 +++--- src/pages/signup/components/SignupTerms.tsx | 2 +- src/pages/signup/components/Step2AccountInfo.tsx | 6 +++--- src/shared/ui/common/Docbar.tsx | 2 +- 9 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/features/home/common/schedule/ui/MonthlyCalendar.tsx b/src/features/home/common/schedule/ui/MonthlyCalendar.tsx index 9a0acbd..34e99f9 100644 --- a/src/features/home/common/schedule/ui/MonthlyCalendar.tsx +++ b/src/features/home/common/schedule/ui/MonthlyCalendar.tsx @@ -80,7 +80,7 @@ export function MonthlyCalendar({ {weekdayLabels.map((label, index) => ( {label} diff --git a/src/features/home/common/schedule/ui/MonthlyDateCell.tsx b/src/features/home/common/schedule/ui/MonthlyDateCell.tsx index 3f69e70..42e1f28 100644 --- a/src/features/home/common/schedule/ui/MonthlyDateCell.tsx +++ b/src/features/home/common/schedule/ui/MonthlyDateCell.tsx @@ -20,7 +20,7 @@ export function MonthlyDateCell({ const dayTextColor = !isCurrentMonth ? 'text-text-50' : isWeekend - ? 'text-[#DC0000]' + ? 'text-error' : 'text-text-50' return ( diff --git a/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts b/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts index f3eb64d..90931b0 100644 --- a/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts +++ b/src/features/home/user/applied-stores/hooks/useAppliedStoresViewModel.ts @@ -70,9 +70,7 @@ export function useAppliedStoresViewModel() { }) const stores = useMemo( - () => - data?.pages.flatMap(page => page.data.map(adaptApplicationDto)) ?? - [], + () => data?.pages.flatMap(page => page.data.map(adaptApplicationDto)) ?? [], [data] ) diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index fc45231..87fa593 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -90,7 +90,7 @@ export function LoginPage() { setPhoneError('') setErrorMessage('') }} - borderColor={phoneError ? '1px solid #DC0000' : undefined} + borderColor={phoneError ? '1px solid error' : undefined} />
diff --git a/src/pages/signup/components/EmailVerification.tsx b/src/pages/signup/components/EmailVerification.tsx index 0d6d3f8..d3d2f28 100644 --- a/src/pages/signup/components/EmailVerification.tsx +++ b/src/pages/signup/components/EmailVerification.tsx @@ -42,9 +42,9 @@ export function EmailVerification({ onChange={e => handleEmailChange(e.target.value)} borderColor={ verified - ? '1px solid #2DE283' + ? '1px solid main' : message && !codeSent - ? '1px solid #DC0000' + ? '1px solid error' : undefined } /> @@ -92,7 +92,7 @@ export function EmailVerification({ {message && (

{message}

diff --git a/src/pages/signup/components/PhoneVerification.tsx b/src/pages/signup/components/PhoneVerification.tsx index b8403d7..6652faa 100644 --- a/src/pages/signup/components/PhoneVerification.tsx +++ b/src/pages/signup/components/PhoneVerification.tsx @@ -38,9 +38,9 @@ export function PhoneVerification({ onChange={e => handlePhoneChange(e.target.value)} borderColor={ verified - ? '1px solid #2DE283' + ? '1px solid main' : message && !smsSent - ? '1px solid #DC0000' + ? '1px solid error' : undefined } /> @@ -88,7 +88,7 @@ export function PhoneVerification({ {message && (

{message}

diff --git a/src/pages/signup/components/SignupTerms.tsx b/src/pages/signup/components/SignupTerms.tsx index f7fe5fd..aa49cb6 100644 --- a/src/pages/signup/components/SignupTerms.tsx +++ b/src/pages/signup/components/SignupTerms.tsx @@ -36,7 +36,7 @@ export function SignupTerms({ className={checkboxCls} /> - (필수) + (필수) 이용약관과{' '} 개인정보 보호정책 에 동의합니다. diff --git a/src/pages/signup/components/Step2AccountInfo.tsx b/src/pages/signup/components/Step2AccountInfo.tsx index 094cad6..0f5c3ce 100644 --- a/src/pages/signup/components/Step2AccountInfo.tsx +++ b/src/pages/signup/components/Step2AccountInfo.tsx @@ -111,9 +111,9 @@ export function Step2AccountInfo({ onChange={e => handleNicknameChange(e.target.value)} borderColor={ nicknameChecked - ? '1px solid #2DE283' + ? '1px solid main' : nicknameCheckMessage - ? '1px solid #DC0000' + ? '1px solid error' : undefined } /> @@ -128,7 +128,7 @@ export function Step2AccountInfo({ {nicknameCheckMessage && (

{nicknameCheckMessage}

diff --git a/src/shared/ui/common/Docbar.tsx b/src/shared/ui/common/Docbar.tsx index 9226f90..9003889 100644 --- a/src/shared/ui/common/Docbar.tsx +++ b/src/shared/ui/common/Docbar.tsx @@ -109,7 +109,7 @@ export function Docbar() { const setSelectedTabByPathname = useDocStore( state => state.setSelectedTabByPathname ) - const { scope } = useAuthStore( ) + const { scope } = useAuthStore() useEffect(() => { setSelectedTabByPathname(pathname) From eebb9176303c501b0374810543aa57e1cb04c6fd Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Tue, 21 Apr 2026 23:48:45 +0900 Subject: [PATCH 30/39] =?UTF-8?q?fix:=20=ED=99=9C=EC=84=B1=20=EC=97=85?= =?UTF-8?q?=EC=9E=A5=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EA=B8=89=EC=97=AC=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/manager/hooks/useManagedWorkspacesQuery.ts | 5 +++-- src/features/home/manager/hooks/useWorkspaceDetailQuery.ts | 2 +- src/features/home/manager/types/posting.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts b/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts index 5b6e0ca..60b281e 100644 --- a/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts +++ b/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts @@ -20,8 +20,9 @@ export function useManagedWorkspacesQuery() { // ID가 가장 작은 업장을 기본값으로 설정 useEffect(() => { - if (workspaces.length === 0) return - if (activeWorkspaceId !== null) return + const hasVaildActiveWorkspace = activeWorkspaceId !== null && workspaces.some(workspaces => workspaces.id === activeWorkspaceId) + + if (hasVaildActiveWorkspace) return const defaultWorkspace = workspaces.reduce((prev, curr) => curr.id < prev.id ? curr : prev diff --git a/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts b/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts index c945f73..0c9e315 100644 --- a/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts +++ b/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts @@ -6,7 +6,7 @@ import { queryKeys } from '@/shared/lib/queryKeys' export function useWorkspaceDetailQuery(workspaceId: number | null) { const { data, isPending, isError } = useQuery({ - queryKey: queryKeys.managerWorkspace.detail(workspaceId ?? 0), + queryKey: queryKeys.managerWorkspace.detail(workspaceId!), queryFn: () => fetchWorkspaceDetail(workspaceId!), enabled: workspaceId !== null, }) diff --git a/src/features/home/manager/types/posting.ts b/src/features/home/manager/types/posting.ts index d570ea2..b627d1f 100644 --- a/src/features/home/manager/types/posting.ts +++ b/src/features/home/manager/types/posting.ts @@ -69,7 +69,7 @@ const WORKING_DAY_KO: Record = { } function formatWage(payAmount: number, paymentType: string): string { - const label = PAYMENT_TYPE_LABEL[paymentType] ?? '시급' + const label = PAYMENT_TYPE_LABEL[paymentType] ?? paymentType const amount = payAmount.toLocaleString('ko-KR') return `${label} ${amount}원` } From 3c04f36f076461505cdabf1e73bb94e7b7e74829 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Tue, 21 Apr 2026 23:49:23 +0900 Subject: [PATCH 31/39] =?UTF-8?q?feat:=20=EA=B0=9C=EC=9D=B8=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=20=EC=A3=BC=EA=B0=84=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=82=A0=EC=A7=9C=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=BF=BC=EB=A6=AC=20=EB=B0=8F=20=EB=B3=91=ED=95=A9?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/user/schedule/api/schedule.ts | 45 +++++++++++++++++-- .../home/user/schedule/lib/date.test.ts | 24 ++++++++-- src/features/home/user/schedule/lib/date.ts | 12 +++++ 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/features/home/user/schedule/api/schedule.ts b/src/features/home/user/schedule/api/schedule.ts index f791e88..ef63892 100644 --- a/src/features/home/user/schedule/api/schedule.ts +++ b/src/features/home/user/schedule/api/schedule.ts @@ -17,6 +17,12 @@ export interface SelfScheduleQueryParams { year?: number month?: number day?: number + fromYear?: number + fromMonth?: number + fromDay?: number + toYear?: number + toMonth?: number + toDay?: number } function mapToCalendarEvent( @@ -48,11 +54,11 @@ export function adaptScheduleResponse( } } -export async function getSelfSchedule( - params?: SelfScheduleQueryParams +async function fetchScheduleByMonth( + year?: number, + month?: number, + day?: number ): Promise { - const { year, month, day } = params ?? {} - try { const response = await axiosInstance.get( '/app/schedules/self', @@ -76,3 +82,34 @@ export async function getSelfSchedule( throw new Error('스케줄 조회 중 오류가 발생했습니다.') } } + +function mergeScheduleResponses( + a: ScheduleApiResponse, + b: ScheduleApiResponse +): ScheduleApiResponse { + return { + ...a, + data: { + totalWorkHours: a.data.totalWorkHours + b.data.totalWorkHours, + schedules: [...a.data.schedules, ...b.data.schedules], + }, + } +} + +export async function getSelfSchedule( + params?: SelfScheduleQueryParams +): Promise { + if (params?.fromYear !== undefined) { + const sameMonth = + params.fromYear === params.toYear && params.fromMonth === params.toMonth + if (sameMonth) { + return fetchScheduleByMonth(params.fromYear, params.fromMonth) + } + const [fromData, toData] = await Promise.all([ + fetchScheduleByMonth(params.fromYear, params.fromMonth), + fetchScheduleByMonth(params.toYear, params.toMonth), + ]) + return mergeScheduleResponses(fromData, toData) + } + return fetchScheduleByMonth(params?.year, params?.month, params?.day) +} diff --git a/src/features/home/user/schedule/lib/date.test.ts b/src/features/home/user/schedule/lib/date.test.ts index 9cb576d..dc5b564 100644 --- a/src/features/home/user/schedule/lib/date.test.ts +++ b/src/features/home/user/schedule/lib/date.test.ts @@ -141,10 +141,28 @@ describe('getScheduleParamsByMode', () => { }) }) - it('weekly이면 year·month만 반환한다', () => { + it('weekly이면 주의 시작·끝 날짜 범위를 반환한다', () => { + // 2026-04-15 (수요일) 기준 주: 2026-04-13(월) ~ 2026-04-19(일) expect(getScheduleParamsByMode(base, 'weekly')).toEqual({ - year: 2026, - month: 4, + fromYear: 2026, + fromMonth: 4, + fromDay: 13, + toYear: 2026, + toMonth: 4, + toDay: 19, + }) + }) + + it('weekly에서 주가 월 경계를 넘으면 두 달을 커버하는 범위를 반환한다', () => { + // 2026-04-28 (화요일) 기준 주: 2026-04-27(월) ~ 2026-05-03(일) + const crossMonth = new Date(2026, 3, 28) + expect(getScheduleParamsByMode(crossMonth, 'weekly')).toEqual({ + fromYear: 2026, + fromMonth: 4, + fromDay: 27, + toYear: 2026, + toMonth: 5, + toDay: 3, }) }) diff --git a/src/features/home/user/schedule/lib/date.ts b/src/features/home/user/schedule/lib/date.ts index 00b97ca..cf016eb 100644 --- a/src/features/home/user/schedule/lib/date.ts +++ b/src/features/home/user/schedule/lib/date.ts @@ -88,6 +88,18 @@ export function getScheduleParamsByMode( if (mode === 'daily') { return { year, month, day: baseDate.getDate() } } + if (mode === 'weekly') { + const weekStart = startOfWeek(baseDate, { weekStartsOn: 1 }) + const weekEnd = endOfWeek(baseDate, { weekStartsOn: 1 }) + return { + fromYear: weekStart.getFullYear(), + fromMonth: weekStart.getMonth() + 1, + fromDay: weekStart.getDate(), + toYear: weekEnd.getFullYear(), + toMonth: weekEnd.getMonth() + 1, + toDay: weekEnd.getDate(), + } + } return { year, month } } From 93a721ad8a437a47e2223bc98d171aebc3a7fd19 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Tue, 21 Apr 2026 23:49:37 +0900 Subject: [PATCH 32/39] =?UTF-8?q?feat:=20=EC=97=85=EC=9E=A5=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EB=82=A0=EC=A7=9C=20=EB=B2=94=EC=9C=84=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/workspace/api/workspaceSchedule.ts | 42 +++++++++++++++++-- .../user/workspace/types/workspaceSchedule.ts | 6 +++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/features/home/user/workspace/api/workspaceSchedule.ts b/src/features/home/user/workspace/api/workspaceSchedule.ts index 251e217..e910667 100644 --- a/src/features/home/user/workspace/api/workspaceSchedule.ts +++ b/src/features/home/user/workspace/api/workspaceSchedule.ts @@ -16,17 +16,53 @@ import type { WorkspaceWorkerItem, } from '@/features/home/user/workspace/types/workspaceSchedule' -export async function getWorkspaceSchedule( +async function fetchWorkspaceScheduleByMonth( workspaceId: number, - params?: WorkspaceScheduleQueryParams + year?: number, + month?: number, + day?: number ): Promise { const response = await axiosInstance.get( `/app/schedules/workspaces/${workspaceId}`, - { params } + { + params: { + ...(year !== undefined && { year }), + ...(month !== undefined && { month }), + ...(day !== undefined && { day }), + }, + } ) return response.data } +export async function getWorkspaceSchedule( + workspaceId: number, + params?: WorkspaceScheduleQueryParams +): Promise { + if (params?.fromYear !== undefined) { + const sameMonth = + params.fromYear === params.toYear && params.fromMonth === params.toMonth + if (sameMonth) { + return fetchWorkspaceScheduleByMonth( + workspaceId, + params.fromYear, + params.fromMonth + ) + } + const [fromData, toData] = await Promise.all([ + fetchWorkspaceScheduleByMonth(workspaceId, params.fromYear, params.fromMonth), + fetchWorkspaceScheduleByMonth(workspaceId, params.toYear, params.toMonth), + ]) + return { ...fromData, data: [...fromData.data, ...toData.data] } + } + return fetchWorkspaceScheduleByMonth( + workspaceId, + params?.year, + params?.month, + params?.day + ) +} + export function adaptWorkspaceScheduleToCalendar( response: WorkspaceScheduleApiResponse ): CalendarViewData { diff --git a/src/features/home/user/workspace/types/workspaceSchedule.ts b/src/features/home/user/workspace/types/workspaceSchedule.ts index df3c180..c680762 100644 --- a/src/features/home/user/workspace/types/workspaceSchedule.ts +++ b/src/features/home/user/workspace/types/workspaceSchedule.ts @@ -25,6 +25,12 @@ export interface WorkspaceScheduleQueryParams { year?: number month?: number day?: number + fromYear?: number + fromMonth?: number + fromDay?: number + toYear?: number + toMonth?: number + toDay?: number } // ---- UI Model ---- From 7544bb8cb14b00afc247b7db1ea534392625e6e9 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 22 Apr 2026 00:15:03 +0900 Subject: [PATCH 33/39] =?UTF-8?q?refactor:=20=EC=97=85=EC=9E=A5=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EB=8B=A8=EC=88=9C=ED=99=94=20=EB=B0=8F=20UI=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/useManagedWorkspacesQuery.ts | 3 +- .../manager/hooks/useWorkspaceDetailQuery.ts | 3 +- src/features/home/manager/types/workspace.ts | 45 +------------------ .../home/manager/ui/WorkspaceChangeCard.tsx | 22 ++------- .../home/manager/ui/WorkspaceChangeList.tsx | 7 +-- src/pages/manager/home/index.tsx | 1 - 6 files changed, 10 insertions(+), 71 deletions(-) diff --git a/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts b/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts index 60b281e..65e2771 100644 --- a/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts +++ b/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts @@ -1,7 +1,6 @@ import { useEffect, useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { fetchManagedWorkspaces } from '@/features/home/manager/api/workspace' -import { adaptWorkspaceItemDto } from '@/features/home/manager/types/workspace' import { useWorkspaceStore } from '@/shared/stores/useWorkspaceStore' import { queryKeys } from '@/shared/lib/queryKeys' @@ -14,7 +13,7 @@ export function useManagedWorkspacesQuery() { }) const workspaces = useMemo( - () => data?.data.map(adaptWorkspaceItemDto) ?? [], + () => data?.data ?? [], [data] ) diff --git a/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts b/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts index 0c9e315..24b7cab 100644 --- a/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts +++ b/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts @@ -1,7 +1,6 @@ import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { fetchWorkspaceDetail } from '@/features/home/manager/api/workspace' -import { adaptWorkspaceDetailDto } from '@/features/home/manager/types/workspace' import { queryKeys } from '@/shared/lib/queryKeys' export function useWorkspaceDetailQuery(workspaceId: number | null) { @@ -12,7 +11,7 @@ export function useWorkspaceDetailQuery(workspaceId: number | null) { }) const detail = useMemo( - () => (data?.data ? adaptWorkspaceDetailDto(data.data) : null), + () => data?.data ?? null, [data] ) diff --git a/src/features/home/manager/types/workspace.ts b/src/features/home/manager/types/workspace.ts index bec37d8..10e5f56 100644 --- a/src/features/home/manager/types/workspace.ts +++ b/src/features/home/manager/types/workspace.ts @@ -9,7 +9,9 @@ export interface WorkspaceStatusDto { export interface WorkspaceItemDto { id: number businessName: string + businessType: string fullAddress: string + createdAt: string status: WorkspaceStatusDto } @@ -27,46 +29,3 @@ export interface WorkspaceDetailDto { export type ManagedWorkspacesApiResponse = CommonApiResponse export type WorkspaceDetailApiResponse = CommonApiResponse -// ---- UI Models ---- -export interface ManagerWorkspaceItem { - id: number - businessName: string - fullAddress: string - status: WorkspaceStatusDto -} - -export interface ManagerWorkspaceDetail { - id: number - businessName: string - businessType: string - contact: string - description: string - fullAddress: string - reputationSummary: string -} - -// ---- Adapters ---- -export function adaptWorkspaceItemDto( - dto: WorkspaceItemDto -): ManagerWorkspaceItem { - return { - id: dto.id, - businessName: dto.businessName, - fullAddress: dto.fullAddress, - status: dto.status, - } -} - -export function adaptWorkspaceDetailDto( - dto: WorkspaceDetailDto -): ManagerWorkspaceDetail { - return { - id: dto.id, - businessName: dto.businessName, - businessType: dto.businessType, - contact: dto.contact, - description: dto.description, - fullAddress: dto.fullAddress, - reputationSummary: dto.reputationSummary, - } -} diff --git a/src/features/home/manager/ui/WorkspaceChangeCard.tsx b/src/features/home/manager/ui/WorkspaceChangeCard.tsx index f0cac9b..6bd58a7 100644 --- a/src/features/home/manager/ui/WorkspaceChangeCard.tsx +++ b/src/features/home/manager/ui/WorkspaceChangeCard.tsx @@ -1,23 +1,10 @@ import EditIcon from '@/assets/icons/home/edit.svg' import { WorkCategoryBadge } from '@/shared/ui/home/WorkCategoryBadge' - -interface WorkspaceStatus { - value: string - description: string -} - -interface WorkspaceChangeItem { - id: number - businessName: string - fullAddress: string - createdAt?: string - status: WorkspaceStatus -} +import type { WorkspaceItemDto } from '@/features/home/manager/types/workspace' interface WorkspaceChangeCardProps { - workspace: WorkspaceChangeItem + workspace: WorkspaceItemDto isSelected?: boolean - categoryLabel?: string className?: string onEdit?: (workspaceId: number) => void onClick?: (workspaceId: number) => void @@ -26,7 +13,6 @@ interface WorkspaceChangeCardProps { export function WorkspaceChangeCard({ workspace, isSelected = false, - categoryLabel = '', className = '', onEdit, onClick, @@ -43,7 +29,7 @@ export function WorkspaceChangeCard({

{workspace.businessName}

- +
@@ -79,4 +65,4 @@ export function WorkspaceChangeCard({ ) } -export type { WorkspaceChangeCardProps, WorkspaceChangeItem, WorkspaceStatus } +export type { WorkspaceChangeCardProps } diff --git a/src/features/home/manager/ui/WorkspaceChangeList.tsx b/src/features/home/manager/ui/WorkspaceChangeList.tsx index ae01c2c..89d8ee2 100644 --- a/src/features/home/manager/ui/WorkspaceChangeList.tsx +++ b/src/features/home/manager/ui/WorkspaceChangeList.tsx @@ -1,12 +1,11 @@ import { WorkspaceChangeCard, - type WorkspaceChangeItem, } from './WorkspaceChangeCard' +import type { WorkspaceItemDto } from '@/features/home/manager/types/workspace' interface WorkspaceChangeListProps { - workspaces: WorkspaceChangeItem[] + workspaces: WorkspaceItemDto[] selectedWorkspaceId?: number - categoryLabel?: string className?: string onSelectWorkspace?: (workspaceId: number) => void onEditWorkspace?: (workspaceId: number) => void @@ -15,7 +14,6 @@ interface WorkspaceChangeListProps { export function WorkspaceChangeList({ workspaces, selectedWorkspaceId, - categoryLabel = '', className = '', onSelectWorkspace, onEditWorkspace, @@ -26,7 +24,6 @@ export function WorkspaceChangeList({ {}} From 7a69dd80d1bf40d78d9e28affbea39436f1c838a Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 22 Apr 2026 00:20:37 +0900 Subject: [PATCH 34/39] =?UTF-8?q?refactor:=20StoreWorkerRole=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=A0=EA=B7=9C=20=EC=83=9D=EC=84=B1=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/manager/types/storeWorkerRole.ts | 3 +++ src/features/home/manager/types/worker.ts | 2 +- src/features/home/manager/ui/StoreWorkerListItem.tsx | 10 +++------- 3 files changed, 7 insertions(+), 8 deletions(-) create mode 100644 src/features/home/manager/types/storeWorkerRole.ts diff --git a/src/features/home/manager/types/storeWorkerRole.ts b/src/features/home/manager/types/storeWorkerRole.ts new file mode 100644 index 0000000..5fba466 --- /dev/null +++ b/src/features/home/manager/types/storeWorkerRole.ts @@ -0,0 +1,3 @@ +import type { WorkerRole } from '@/shared/ui/home/WorkerRoleBadge' + +export type StoreWorkerRole = WorkerRole diff --git a/src/features/home/manager/types/worker.ts b/src/features/home/manager/types/worker.ts index 59b8b2d..634909d 100644 --- a/src/features/home/manager/types/worker.ts +++ b/src/features/home/manager/types/worker.ts @@ -1,5 +1,5 @@ import type { CommonApiResponse } from '@/shared/types/common' -import type { StoreWorkerRole } from '@/features/home/manager/ui/StoreWorkerListItem' +import type { StoreWorkerRole } from '@/features/home/manager/types/storeWorkerRole' // ---- API DTOs ---- export interface WorkerUserDto { diff --git a/src/features/home/manager/ui/StoreWorkerListItem.tsx b/src/features/home/manager/ui/StoreWorkerListItem.tsx index 21ee23f..dedba76 100644 --- a/src/features/home/manager/ui/StoreWorkerListItem.tsx +++ b/src/features/home/manager/ui/StoreWorkerListItem.tsx @@ -1,10 +1,6 @@ import MoreVerticalIcon from '@/assets/icons/home/more-vertical.svg' -import { - WorkerRoleBadge, - type WorkerRoleBadgeProps, -} from '@/shared/ui/home/WorkerRoleBadge' - -type StoreWorkerRole = WorkerRoleBadgeProps['role'] +import { WorkerRoleBadge } from '@/shared/ui/home/WorkerRoleBadge' +import type { StoreWorkerRole } from '@/features/home/manager/types/storeWorkerRole' interface StoreWorkerListItemProps { name: string @@ -62,4 +58,4 @@ export function StoreWorkerListItem({ ) } -export type { StoreWorkerListItemProps, StoreWorkerRole } +export type { StoreWorkerListItemProps } From a29b123250d1f78f36bbde4c9d3120f2518c100e Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 22 Apr 2026 03:31:02 +0900 Subject: [PATCH 35/39] =?UTF-8?q?fix:=20=ED=98=84=EC=9E=AC=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90=20=EC=9D=B4=ED=9B=84=EC=9D=98=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EC=9E=90=20=EC=9D=BC=EC=A0=95=EB=A7=8C=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manager/hooks/useManagedWorkspacesQuery.ts | 9 ++++----- .../manager/hooks/useWorkspaceDetailQuery.ts | 5 +---- src/features/home/manager/types/workspace.ts | 1 - .../home/manager/ui/WorkspaceChangeList.tsx | 4 +--- .../user/workspace/api/workspaceSchedule.ts | 18 +++++++++++++----- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts b/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts index 65e2771..ec9ef3c 100644 --- a/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts +++ b/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts @@ -12,14 +12,13 @@ export function useManagedWorkspacesQuery() { queryFn: fetchManagedWorkspaces, }) - const workspaces = useMemo( - () => data?.data ?? [], - [data] - ) + const workspaces = useMemo(() => data?.data ?? [], [data]) // ID가 가장 작은 업장을 기본값으로 설정 useEffect(() => { - const hasVaildActiveWorkspace = activeWorkspaceId !== null && workspaces.some(workspaces => workspaces.id === activeWorkspaceId) + const hasVaildActiveWorkspace = + activeWorkspaceId !== null && + workspaces.some(workspaces => workspaces.id === activeWorkspaceId) if (hasVaildActiveWorkspace) return diff --git a/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts b/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts index 24b7cab..7d0beb6 100644 --- a/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts +++ b/src/features/home/manager/hooks/useWorkspaceDetailQuery.ts @@ -10,10 +10,7 @@ export function useWorkspaceDetailQuery(workspaceId: number | null) { enabled: workspaceId !== null, }) - const detail = useMemo( - () => data?.data ?? null, - [data] - ) + const detail = useMemo(() => data?.data ?? null, [data]) return { detail, diff --git a/src/features/home/manager/types/workspace.ts b/src/features/home/manager/types/workspace.ts index 10e5f56..c568f0a 100644 --- a/src/features/home/manager/types/workspace.ts +++ b/src/features/home/manager/types/workspace.ts @@ -28,4 +28,3 @@ export interface WorkspaceDetailDto { // ---- API Response Types ---- export type ManagedWorkspacesApiResponse = CommonApiResponse export type WorkspaceDetailApiResponse = CommonApiResponse - diff --git a/src/features/home/manager/ui/WorkspaceChangeList.tsx b/src/features/home/manager/ui/WorkspaceChangeList.tsx index 89d8ee2..ff4412e 100644 --- a/src/features/home/manager/ui/WorkspaceChangeList.tsx +++ b/src/features/home/manager/ui/WorkspaceChangeList.tsx @@ -1,6 +1,4 @@ -import { - WorkspaceChangeCard, -} from './WorkspaceChangeCard' +import { WorkspaceChangeCard } from './WorkspaceChangeCard' import type { WorkspaceItemDto } from '@/features/home/manager/types/workspace' interface WorkspaceChangeListProps { diff --git a/src/features/home/user/workspace/api/workspaceSchedule.ts b/src/features/home/user/workspace/api/workspaceSchedule.ts index e910667..e9fbd94 100644 --- a/src/features/home/user/workspace/api/workspaceSchedule.ts +++ b/src/features/home/user/workspace/api/workspaceSchedule.ts @@ -50,7 +50,11 @@ export async function getWorkspaceSchedule( ) } const [fromData, toData] = await Promise.all([ - fetchWorkspaceScheduleByMonth(workspaceId, params.fromYear, params.fromMonth), + fetchWorkspaceScheduleByMonth( + workspaceId, + params.fromYear, + params.fromMonth + ), fetchWorkspaceScheduleByMonth(workspaceId, params.toYear, params.toMonth), ]) return { ...fromData, data: [...fromData.data, ...toData.data] } @@ -115,11 +119,15 @@ export function deriveWorkerList( response: WorkspaceScheduleApiResponse ): WorkspaceWorkerItem[] { const workerMap = new Map() + const now = Date.now() - const sorted = [...response.data].sort( - (a, b) => - new Date(a.startDateTime).getTime() - new Date(b.startDateTime).getTime() - ) + const sorted = response.data + .filter(shift => new Date(shift.startDateTime).getTime() >= now) + .sort( + (a, b) => + new Date(a.startDateTime).getTime() - + new Date(b.startDateTime).getTime() + ) for (const shift of sorted) { const { workerId, workerName } = shift.assignedWorker From 7abce02a46304daf7ed1e598f027a676acfbab79 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 22 Apr 2026 03:33:47 +0900 Subject: [PATCH 36/39] =?UTF-8?q?fix:=20=EC=97=85=EC=9E=A5=20=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20pageSize=20?= =?UTF-8?q?=EB=B3=80=ED=99=94=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=ED=82=A4=20=EA=B0=B1=EC=8B=A0=20=EB=A1=9C=EC=A7=81?= =?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 --- .../user/workspace/hooks/useWorkspaceManagersViewModel.ts | 4 ++-- .../home/user/workspace/hooks/useWorkspaceWorkersViewModel.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts b/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts index d771b4d..f59ab6c 100644 --- a/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts +++ b/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts @@ -17,7 +17,7 @@ export function useWorkspaceManagersViewModel( isPending, isError, } = useInfiniteQuery({ - queryKey: queryKeys.workspace.managers(workspaceId), + queryKey: [...queryKeys.workspace.managers(workspaceId), { pageSize }], queryFn: ({ pageParam }) => getWorkspaceManagers(workspaceId, { pageSize, @@ -39,4 +39,4 @@ export function useWorkspaceManagersViewModel( isLoading: isPending, isError, } -} +} \ No newline at end of file diff --git a/src/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel.ts b/src/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel.ts index 601a694..91efcc7 100644 --- a/src/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel.ts +++ b/src/features/home/user/workspace/hooks/useWorkspaceWorkersViewModel.ts @@ -17,7 +17,7 @@ export function useWorkspaceWorkersViewModel( isPending, isError, } = useInfiniteQuery({ - queryKey: queryKeys.workspace.workers(workspaceId), + queryKey: [...queryKeys.workspace.workers(workspaceId), { pageSize }], queryFn: ({ pageParam }) => getWorkspaceWorkers(workspaceId, { pageSize, From a3bd6c77a8596130fc9b5e3b36880ae9e4fe691f Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 22 Apr 2026 03:36:36 +0900 Subject: [PATCH 37/39] =?UTF-8?q?chore:=20=EC=A0=80=EC=9E=A5=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20TODO=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/manager/worker-schedule/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/manager/worker-schedule/index.tsx b/src/pages/manager/worker-schedule/index.tsx index 8ab903a..2ef8154 100644 --- a/src/pages/manager/worker-schedule/index.tsx +++ b/src/pages/manager/worker-schedule/index.tsx @@ -161,6 +161,9 @@ export function ManagerWorkerSchedulePage() { From c75377f48adba475eb5df8a6c354311306f29b32 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 22 Apr 2026 03:39:05 +0900 Subject: [PATCH 38/39] =?UTF-8?q?refactor:=20queryKeys=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=EC=97=90=EC=84=9C=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EB=8F=99=EC=A0=81=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/workspace/hooks/useWorkspaceManagersViewModel.ts | 2 +- src/shared/lib/queryKeys.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts b/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts index f59ab6c..65a04a8 100644 --- a/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts +++ b/src/features/home/user/workspace/hooks/useWorkspaceManagersViewModel.ts @@ -39,4 +39,4 @@ export function useWorkspaceManagersViewModel( isLoading: isPending, isError, } -} \ No newline at end of file +} diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index d15f32b..227b291 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -7,10 +7,10 @@ export const queryKeys = { ['schedules', 'self', params] as const, }, workspace: { - workers: (workspaceId?: number, cursor?: string, pageSize?: number) => - ['workspace', 'workers', workspaceId, cursor, pageSize] as const, - managers: (workspaceId?: number, cursor?: string, pageSize?: number) => - ['workspace', 'managers', workspaceId, cursor, pageSize] as const, + workers: (workspaceId?: number) => + ['workspace', 'workers', workspaceId] as const, + managers: (workspaceId?: number) => + ['workspace', 'managers', workspaceId] as const, list: (params?: { pageSize: number }) => ['workspace', 'list', params] as const, schedules: ( From 46a1681378b585c31aa6ad7bbbf02ea5a31d8c39 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Wed, 22 Apr 2026 03:53:28 +0900 Subject: [PATCH 39/39] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=20=EC=97=85?= =?UTF-8?q?=EC=9E=A5=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/manager/hooks/useManagedWorkspacesQuery.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts b/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts index ec9ef3c..21a83d0 100644 --- a/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts +++ b/src/features/home/manager/hooks/useManagedWorkspacesQuery.ts @@ -16,11 +16,12 @@ export function useManagedWorkspacesQuery() { // ID가 가장 작은 업장을 기본값으로 설정 useEffect(() => { - const hasVaildActiveWorkspace = + if (workspaces.length === 0) return + const hasValidActiveWorkspace = activeWorkspaceId !== null && - workspaces.some(workspaces => workspaces.id === activeWorkspaceId) + workspaces.some(workspace => workspace.id === activeWorkspaceId) - if (hasVaildActiveWorkspace) return + if (hasValidActiveWorkspace) return const defaultWorkspace = workspaces.reduce((prev, curr) => curr.id < prev.id ? curr : prev