diff --git a/.gitignore b/.gitignore index 2bbd80d8..57f2ee97 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,7 @@ coverage *storybook.log storybook-static -.serena \ No newline at end of file +.serena + +CLAUDE.md +.claude diff --git a/src/assets/default-profile.svg b/src/assets/default-profile.svg new file mode 100644 index 00000000..eff724aa --- /dev/null +++ b/src/assets/default-profile.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/features/manager/home/lib/worker.ts b/src/features/manager/home/lib/worker.ts index 471558ea..855bb9cf 100644 --- a/src/features/manager/home/lib/worker.ts +++ b/src/features/manager/home/lib/worker.ts @@ -28,5 +28,6 @@ export function adaptWorkerDto(dto: WorkerDto): ManagerWorkerItem { position: dto.position.description || dto.position.type, colorCode: dto.colorCode, nextWorkDate: formatNextShiftDate(dto.nextShiftDateTime), + profileImageUrl: dto.user.profileImageUrl ?? undefined, } } diff --git a/src/features/manager/home/types/substitute.ts b/src/features/manager/home/types/substitute.ts index ff302eb6..09455e62 100644 --- a/src/features/manager/home/types/substitute.ts +++ b/src/features/manager/home/types/substitute.ts @@ -15,6 +15,7 @@ export interface SubstituteRequesterDto { workerId: number workerName: string workerRole?: string + profileImageUrl?: string | null } export interface SubstituteStatusDto { @@ -87,6 +88,7 @@ export function adaptSubstituteRequestDto( name: dto.requester.workerName, role: dto.schedule.position, workerRole: mapApiWorkerRole(dto.requester.workerRole), + imageUrl: dto.requester.profileImageUrl ?? null, dateRange: formatDateRange( dto.schedule.startDateTime, dto.schedule.endDateTime diff --git a/src/features/manager/home/types/worker.ts b/src/features/manager/home/types/worker.ts index 8565599d..259184c6 100644 --- a/src/features/manager/home/types/worker.ts +++ b/src/features/manager/home/types/worker.ts @@ -7,6 +7,7 @@ export interface WorkerUserDto { name: string contact: string gender: string + profileImageUrl?: string | null } export interface WorkerStatusDto { diff --git a/src/features/manager/home/ui/StoreWorkerListItem.tsx b/src/features/manager/home/ui/StoreWorkerListItem.tsx index 79a75f5c..d84e5ced 100644 --- a/src/features/manager/home/ui/StoreWorkerListItem.tsx +++ b/src/features/manager/home/ui/StoreWorkerListItem.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import MoreVerticalIcon from '@/assets/icons/home/more-vertical.svg' import { WorkerRoleBadge } from '@/shared/ui/home/WorkerRoleBadge' import { ActionMenu, type ActionMenuItem } from '@/shared/ui/common/ActionMenu' +import { Avatar } from '@/shared/ui/common/Avatar' import type { StoreWorkerRole } from '@/features/manager/home/types/storeWorkerRole' interface StoreWorkerListItemProps { @@ -28,15 +29,7 @@ export function StoreWorkerListItem({ className={`relative flex h-[60px] items-center justify-between rounded-lg bg-white px-3 py-1 ${className}`} >
-
- {profileImageUrl ? ( - {name} - ) : null} -
+
diff --git a/src/features/manager/home/ui/TodayWorkerList.tsx b/src/features/manager/home/ui/TodayWorkerList.tsx index dde39b71..3407a02e 100644 --- a/src/features/manager/home/ui/TodayWorkerList.tsx +++ b/src/features/manager/home/ui/TodayWorkerList.tsx @@ -1,3 +1,5 @@ +import { Avatar } from '@/shared/ui/common/Avatar' + interface TodayWorkerItem { id: number | string name: string @@ -13,15 +15,7 @@ interface TodayWorkerListProps { function TodayWorkerCard({ worker }: { worker: TodayWorkerItem }) { return (
-
- {worker.profileImageUrl ? ( - {worker.name} - ) : null} -
+

{worker.name}

diff --git a/src/features/manager/worker-list/ui/WorkerListItem.tsx b/src/features/manager/worker-list/ui/WorkerListItem.tsx index 3058925c..b47ac118 100644 --- a/src/features/manager/worker-list/ui/WorkerListItem.tsx +++ b/src/features/manager/worker-list/ui/WorkerListItem.tsx @@ -4,6 +4,7 @@ import type { ScheduleColor } from '@/features/manager/worker-schedule/types/sch import { SwipeActionButton } from '@/shared/ui/SwipeActionButton' import { SwipeableRow } from '@/shared/ui/SwipeableRow' import { WorkerRoleBadge } from '@/shared/ui/home/WorkerRoleBadge' +import { Avatar } from '@/shared/ui/common/Avatar' import type { WorkerRole } from '@/shared/types/workerRole' interface WorkerListItemProps { @@ -30,16 +31,8 @@ export function WorkerListItem({ const content = (
-
- {profileImageUrl ? ( - {`${name} - ) : ( -
- )} +
+
{ + it('RECEIVED 목록은 요청자의 profileImageUrl을 imageUrl로 매핑한다', () => { + const item = adaptUserSubstituteListItem( + received('https://img/requester.png'), + 'RECEIVED' + ) + expect(item.imageUrl).toBe('https://img/requester.png') + }) + + it('RECEIVED 상세는 요청자의 profileImageUrl을 imageUrl로 매핑한다', () => { + const detail = adaptReceivedSubstituteDetail( + received('https://img/requester.png') + ) + expect(detail.imageUrl).toBe('https://img/requester.png') + }) + + it('SENT은 수락자가 있으면 수락자의 이미지를 사용한다', () => { + const accepted = { + workerId: 9, + workerName: '수락자', + profileImageUrl: 'https://img/accepted.png', + } + const targets = [target('https://img/target.png')] + + expect( + adaptUserSubstituteListItem( + sentList({ acceptedWorker: accepted, targets }), + 'SENT' + ).imageUrl + ).toBe('https://img/accepted.png') + expect( + adaptSentSubstituteDetail( + sentDetail({ acceptedWorker: accepted, targets }) + ).imageUrl + ).toBe('https://img/accepted.png') + }) + + it('SENT은 수락자가 없으면 첫 대상자의 이미지를 사용한다', () => { + const targets = [target('https://img/target.png')] + + expect( + adaptUserSubstituteListItem(sentList({ targets }), 'SENT').imageUrl + ).toBe('https://img/target.png') + expect(adaptSentSubstituteDetail(sentDetail({ targets })).imageUrl).toBe( + 'https://img/target.png' + ) + }) + + it('이미지 필드가 없으면 null로 정규화한다', () => { + expect( + adaptUserSubstituteListItem(received(), 'RECEIVED').imageUrl + ).toBeNull() + expect(adaptReceivedSubstituteDetail(received()).imageUrl).toBeNull() + expect( + adaptUserSubstituteListItem(sentList({ targets: [target()] }), 'SENT') + .imageUrl + ).toBeNull() + }) + + it('normalize는 API target의 profileImageUrl을 정규화 target으로 전달한다', () => { + const api: SentSubstituteRequestDetailApiDto = { + id: 4, + schedule, + workspace, + requester: { workerId: 5, workerName: '요청자' }, + requestType: 'SPECIFIC', + targets: [ + { + target: { + workerId: 7, + workerName: '대상자', + profileImageUrl: 'https://img/target.png', + }, + status: 'PENDING', + }, + ], + status: 'PENDING', + createdAt: '2026-04-01T00:00:00', + } + + expect( + normalizeSentSubstituteDetailDto(api).targets[0].profileImageUrl + ).toBe('https://img/target.png') + }) +}) diff --git a/src/features/user/substitute/lib/adaptUserSubstituteRequest.ts b/src/features/user/substitute/lib/adaptUserSubstituteRequest.ts index eaa3467a..3197d655 100644 --- a/src/features/user/substitute/lib/adaptUserSubstituteRequest.ts +++ b/src/features/user/substitute/lib/adaptUserSubstituteRequest.ts @@ -40,6 +40,7 @@ export function normalizeSentSubstituteDetailDto( targetId: item.target.workerId, workerName: item.target.workerName, targetName: item.target.workerName, + profileImageUrl: item.target.profileImageUrl ?? null, status: unwrapSubstituteEnum(item.status), rejectionReason: item.rejectionReason, respondedAt: item.respondedAt, @@ -166,6 +167,25 @@ function resolveSentPersonName(dto: { return requestType === 'ALL' ? '전체 공개' : '대상' } +/** resolveSentPersonName과 동일 우선순위(수락자 → 첫 대상자)로 이미지를 해석 */ +function resolveSentPersonImage(dto: { + targets?: SubstituteTargetDto[] + acceptedWorker?: { + workerName?: string + profileImageUrl?: string | null + } | null +}): string | null { + const accepted = dto.acceptedWorker + if (accepted) { + return accepted.profileImageUrl ?? null + } + const firstTarget = dto.targets?.[0] + if (resolveTargetWorkerName(firstTarget)) { + return firstTarget?.profileImageUrl ?? null + } + return null +} + function resolveSentRequestType( dto: Pick ): SubstituteRequestType { @@ -187,7 +207,7 @@ function adaptReceivedListItem( scheduledDateLabel: formatScheduledDate(dto.schedule.startDateTime), uiStatus, statusLabel: statusLabelForApi(rawStatus, uiStatus), - imageUrl: null, + imageUrl: dto.requester.profileImageUrl ?? null, rawStatus, dto, } @@ -212,7 +232,7 @@ function adaptSentListItem( scheduledDateLabel: formatScheduledDate(dto.schedule.startDateTime), uiStatus, statusLabel: statusLabelForApi(rawStatus, uiStatus), - imageUrl: null, + imageUrl: resolveSentPersonImage(dto) ?? null, rawStatus, dto, } @@ -236,6 +256,7 @@ function detailFromSchedulePerson( requestReason?: string | null }, personName: string, + personImage: string | null, direction: SubstituteRequestDirection ): UserSubstituteDetailViewModel { const storeName = dto.workspace.workspaceName?.trim() ?? '매장' @@ -247,6 +268,7 @@ function detailFromSchedulePerson( id: 0, displayName: `${storeName} / ${personName}`, role: positionToRole(dto.schedule.position), + imageUrl: personImage, dateTitle: formatDetailDateTitle(dto.schedule.startDateTime), totalHoursLabel: totalHoursLabel( dto.schedule.startDateTime, @@ -271,6 +293,7 @@ export function adaptReceivedSubstituteDetail( ...detailFromSchedulePerson( dto, dto.requester.workerName?.trim() ?? '이름', + dto.requester.profileImageUrl ?? null, 'RECEIVED' ), id: dto.id, @@ -281,9 +304,10 @@ export function adaptSentSubstituteDetail( dto: SentSubstituteRequestDetailDto ): UserSubstituteDetailViewModel { const personName = resolveSentPersonName(dto) + const personImage = resolveSentPersonImage(dto) return { - ...detailFromSchedulePerson(dto, personName, 'SENT'), + ...detailFromSchedulePerson(dto, personName, personImage, 'SENT'), id: dto.id, } } diff --git a/src/features/user/substitute/types.ts b/src/features/user/substitute/types.ts index ba0c2a56..ddc09b49 100644 --- a/src/features/user/substitute/types.ts +++ b/src/features/user/substitute/types.ts @@ -27,6 +27,7 @@ export interface SubstituteEnumValueDto { export interface SubstituteTargetWorkerDto { workerId: number workerName: string + profileImageUrl?: string | null } /** GET sent/{requestId} — targets[] 항목 */ @@ -52,11 +53,13 @@ export interface SubstituteWorkspaceDto { export interface SubstituteRequesterDto { workerId: number workerName: string + profileImageUrl?: string | null } export interface SubstituteAcceptedWorkerDto { workerId: number workerName: string + profileImageUrl?: string | null } /** 어댑터용 정규화된 대상 */ @@ -64,6 +67,7 @@ export interface SubstituteTargetDto { targetId: number workerName: string targetName?: string + profileImageUrl?: string | null status: string rejectionReason?: string | null respondedAt?: string | null @@ -189,6 +193,7 @@ export interface UserSubstituteDetailViewModel { id: number displayName: string role: WorkerRole + imageUrl?: string | null dateTitle: string totalHoursLabel: string startTimeLabel: string diff --git a/src/pages/manager/substitute-request/index.tsx b/src/pages/manager/substitute-request/index.tsx index e91522a5..77b1279d 100644 --- a/src/pages/manager/substitute-request/index.tsx +++ b/src/pages/manager/substitute-request/index.tsx @@ -1,7 +1,7 @@ import DownIcon from '@/assets/icons/home/chevron-down.svg?react' import { Navbar } from '@/shared/ui/common/Navbar' import { Spinner } from '@/shared/ui/Spinner' -import { SubstituteProfileAvatar } from '@/pages/user/substitute-request/components/SubstituteProfileAvatar' +import { Avatar } from '@/shared/ui/common/Avatar' import { SubstituteRequestResponseActions } from '@/pages/user/substitute-request/components/SubstituteRequestResponseActions' import { SubstituteRequestStatusBadge } from '@/pages/user/substitute-request/components/SubstituteRequestStatusBadge' import { useManagerSubstituteRequestViewModel } from '@/features/manager/substitute/hooks/useManagerSubstituteRequestViewModel' @@ -12,7 +12,11 @@ import type { SubstituteRequestItem } from '@/shared/types/substituteRequest' function CardBase({ item }: { item: SubstituteRequestItem }) { return ( <> - +
diff --git a/src/pages/manager/worker-schedule/components/WorkerSelectSection.tsx b/src/pages/manager/worker-schedule/components/WorkerSelectSection.tsx index d9a0b6ee..d5d8a4fb 100644 --- a/src/pages/manager/worker-schedule/components/WorkerSelectSection.tsx +++ b/src/pages/manager/worker-schedule/components/WorkerSelectSection.tsx @@ -1,3 +1,4 @@ +import { Avatar } from '@/shared/ui/common/Avatar' import { WorkerRoleBadge } from '@/shared/ui/home/WorkerRoleBadge' import chevronDownIcon from '@/assets/icons/home/chevron-down.svg' import type { ManagerWorkerItem } from '@/features/manager/home/types/worker' @@ -11,15 +12,6 @@ interface WorkerSelectSectionProps { onSelectWorker: (workerId: number) => void } -function WorkerAvatar() { - return ( -