Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 100 additions & 11 deletions src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DocumentItemResponse, WorkerDocumentResponse } from '../../api/documents'
import type { TaskSummaryResponse } from '../../api/tasks'
import type { WorkerResponse } from '../../api/workers'
import { WorkerDetailPage } from './WorkerDetailPage'

Expand All @@ -16,7 +17,15 @@ function jsonResponse(body: unknown, init: ResponseInit = {}) {

function errorResponse(status: number, code: string, message: string) {
return jsonResponse(
{ timestamp: '2026-07-27T01:23:45Z', status, code, message, path: '/api/v1/workers/W-1', request_id: 'req-1', field_errors: [] },
{
timestamp: '2026-07-27T01:23:45Z',
status,
code,
message,
path: '/api/v1/workers/W-1',
request_id: 'req-1',
field_errors: [],
},
{ status },
)
}
Expand Down Expand Up @@ -55,17 +64,51 @@ function document(overrides: Partial<DocumentItemResponse> = {}): DocumentItemRe
}
}

function mockWorkerAndDocuments(workerOverrides: Partial<WorkerResponse> = {}, documents: DocumentItemResponse[] = []) {
function task(overrides: Partial<TaskSummaryResponse> = {}): TaskSummaryResponse {
return {
task_id: 'T-1',
target_type: 'WORKER',
worker_id: 'W-018',
case_id: null,
task_type: 'DOCUMENT_REQUEST',
workflow_id: 'WF-DOC-001',
workflow_catalog_version: '1',
title: '여권 사본 요청',
source: 'MANUAL',
status: 'WAITING_WORKER',
due_date: '2026-08-20',
content_revision: 1,
version: 1,
created_at: '2026-08-01T00:00:00Z',
updated_at: '2026-08-01T00:00:00Z',
...overrides,
}
}

function mockWorkerAndDocuments(
workerOverrides: Partial<WorkerResponse> = {},
documents: DocumentItemResponse[] = [],
tasks: TaskSummaryResponse[] = [],
) {
vi.mocked(fetch).mockImplementation((input) => {
const url = String(input)
if (url.includes('/documents')) {
return Promise.resolve(jsonResponse({ items: documents, page: 0, size: 100, total_elements: documents.length }))
return Promise.resolve(
jsonResponse({ items: documents, page: 0, size: 100, total_elements: documents.length }),
)
}
if (url.includes('/tasks')) {
return Promise.resolve(
jsonResponse({ items: tasks, page: 0, size: 20, total_elements: tasks.length }),
)
}
return Promise.resolve(jsonResponse(worker(workerOverrides)))
})
}

function registeredDocument(overrides: Partial<WorkerDocumentResponse> = {}): WorkerDocumentResponse {
function registeredDocument(
overrides: Partial<WorkerDocumentResponse> = {},
): WorkerDocumentResponse {
return {
worker_document_id: 'D-2',
worker_id: 'W-018',
Expand All @@ -88,6 +131,9 @@ function mockWorkerError(status: number, code: string, message: string) {
if (url.includes('/documents')) {
return Promise.resolve(jsonResponse({ items: [], page: 0, size: 100, total_elements: 0 }))
}
if (url.includes('/tasks')) {
return Promise.resolve(jsonResponse({ items: [], page: 0, size: 20, total_elements: 0 }))
}
return Promise.resolve(errorResponse(status, code, message))
})
}
Expand Down Expand Up @@ -143,6 +189,23 @@ describe('WorkerDetailPage', () => {
expect(await screen.findByText('제출된 서류가 없습니다')).toBeInTheDocument()
})

it("shows the worker's real current tasks with a link to each task", async () => {
mockWorkerAndDocuments({ display_name: '쩐티B' }, [], [task()])
renderPage('W-018')

const taskLink = await screen.findByRole('link', { name: /여권 사본 요청/ })
expect(taskLink).toHaveAttribute('href', '/tasks/T-1')
expect(screen.getByText('근로자 응답 대기')).toBeInTheDocument()
expect(screen.getByText('~2026-08-20')).toBeInTheDocument()
})

it('shows an empty state when the worker has no current tasks', async () => {
mockWorkerAndDocuments({ display_name: '쩐티B' }, [], [])
renderPage('W-018')

expect(await screen.findByText('진행 중인 업무가 없습니다')).toBeInTheDocument()
})

it('shows a loading state', () => {
vi.mocked(fetch).mockReturnValue(new Promise(() => {}))
renderPage('W-018')
Expand All @@ -165,8 +228,19 @@ describe('WorkerDetailPage', () => {
const method = init?.method ?? 'GET'
if (url.includes('/documents') && method === 'GET') {
documentsGetCount += 1
const items = documentsGetCount === 1 ? [] : [document({ worker_document_id: 'D-2', document_type: 'PASSPORT_COPY', expiry_date: null })]
return Promise.resolve(jsonResponse({ items, page: 0, size: 100, total_elements: items.length }))
const items =
documentsGetCount === 1
? []
: [
document({
worker_document_id: 'D-2',
document_type: 'PASSPORT_COPY',
expiry_date: null,
}),
]
return Promise.resolve(
jsonResponse({ items, page: 0, size: 100, total_elements: items.length }),
)
}
if (url.includes('/documents') && method === 'POST') {
return Promise.resolve(jsonResponse(registeredDocument(), { status: 201 }))
Expand Down Expand Up @@ -198,7 +272,13 @@ describe('WorkerDetailPage', () => {
if (url.includes('/files') && method === 'POST') {
return Promise.resolve(
jsonResponse(
{ file_id: 'file-1', name: 'passport.png', mime_type: 'image/png', size: 1024, scan_status: 'NOT_SCANNED' },
{
file_id: 'file-1',
name: 'passport.png',
mime_type: 'image/png',
size: 1024,
scan_status: 'NOT_SCANNED',
},
{ status: 201 },
),
)
Expand All @@ -222,7 +302,9 @@ describe('WorkerDetailPage', () => {
await screen.findByText('제출된 서류가 없습니다')

expect(calls.some((c) => c.url.includes('/files') && c.method === 'POST')).toBe(true)
expect(calls.some((c) => c.url.includes('/workers/W-018/documents') && c.method === 'POST')).toBe(true)
expect(
calls.some((c) => c.url.includes('/workers/W-018/documents') && c.method === 'POST'),
).toBe(true)
expect(calls.some((c) => c.url.includes('/documents/D-2') && c.method === 'PATCH')).toBe(true)
})

Expand All @@ -242,7 +324,9 @@ describe('WorkerDetailPage', () => {
}
workerGetCount += 1
return Promise.resolve(
jsonResponse(worker(workerGetCount === 1 ? {} : { display_name: '쩐티B(수정)', version: 2 })),
jsonResponse(
worker(workerGetCount === 1 ? {} : { display_name: '쩐티B(수정)', version: 2 }),
),
)
})
renderPage('W-018')
Expand All @@ -259,7 +343,10 @@ describe('WorkerDetailPage', () => {
expect(await screen.findByRole('heading', { name: '쩐티B(수정)' })).toBeInTheDocument()
const patchCall = calls.find((c) => c.url.includes('/workers/W-018') && c.method === 'PATCH')
expect(patchCall).toBeDefined()
expect(JSON.parse(patchCall!.body!)).toMatchObject({ display_name: '쩐티B(수정)', expected_version: 1 })
expect(JSON.parse(patchCall!.body!)).toMatchObject({
display_name: '쩐티B(수정)',
expected_version: 1,
})
})

it('does not report success when an existing employment date is cleared', async () => {
Expand All @@ -273,7 +360,9 @@ describe('WorkerDetailPage', () => {
await user.click(screen.getByRole('button', { name: '저장' }))

expect(
screen.getByText('등록된 비자·날짜 값의 삭제는 아직 지원하지 않습니다. 기존 값을 유지해 주세요.'),
screen.getByText(
'등록된 비자·날짜 값의 삭제는 아직 지원하지 않습니다. 기존 값을 유지해 주세요.',
),
).toBeInTheDocument()
expect(vi.mocked(fetch).mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false)
})
Expand Down
63 changes: 53 additions & 10 deletions src/pages/WorkerDetailPage/WorkerDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { useCallback, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { fetchDocuments } from '../../api/documents'
import { fetchTasks } from '../../api/tasks'
import { fetchWorkerById } from '../../api/workers'
import { getErrorMessage } from '../../api/errors'
import { DetailRow } from '../../components/ui/DetailRow/DetailRow'
import { EmptyState } from '../../components/ui/EmptyState/EmptyState'
import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel'
import { WorkerFormModal } from '../../components/worker/WorkerFormModal'
import { useApiQuery } from '../../hooks/useApiQuery'
import { TASK_STATUS_LABEL, TASK_STATUS_TONE } from '../../utils/taskStatus'
import { getDocumentViewModel } from '../../view-models/documentViewModel'
import { getOperationalDateViewModel } from '../../view-models/dateViewModel'
import { RegisterDocumentModal } from './overlays/RegisterDocumentModal'
Expand All @@ -19,12 +21,16 @@ export function WorkerDetailPage() {
const fetcher = useCallback(() => fetchWorkerById(workerId ?? ''), [workerId])
const { status, data: worker, error, refetch } = useApiQuery(fetcher)

const {
data: documentPage,
refetch: refetchDocuments,
} = useApiQuery(useCallback(() => fetchDocuments({ workerId: workerId ?? '', size: 100 }), [workerId]))
const { data: documentPage, refetch: refetchDocuments } = useApiQuery(
useCallback(() => fetchDocuments({ workerId: workerId ?? '', size: 100 }), [workerId]),
)
const workerDocuments = documentPage?.items ?? []

const { data: taskPage } = useApiQuery(
useCallback(() => fetchTasks({ workerId: workerId ?? '', size: 20 }), [workerId]),
)
const workerTasks = taskPage?.items ?? []

const [registerModalOpen, setRegisterModalOpen] = useState(false)
const [editModalOpen, setEditModalOpen] = useState(false)

Expand Down Expand Up @@ -89,7 +95,11 @@ export function WorkerDetailPage() {
<div className={styles.sectionCard}>
<div className={styles.cardHeaderRow}>
<h2 className={styles.cardTitle}>기본정보</h2>
<button type="button" className={styles.cardHeaderButton} onClick={() => setEditModalOpen(true)}>
<button
type="button"
className={styles.cardHeaderButton}
onClick={() => setEditModalOpen(true)}
>
정보 수정
</button>
</div>
Expand All @@ -101,7 +111,13 @@ export function WorkerDetailPage() {
<DetailRow
label={stayExpiry.label}
value={stayExpiry.display}
tone={stayExpiry.tone === 'critical' ? 'critical' : stayExpiry.tone === 'warning' ? 'warning' : 'default'}
tone={
stayExpiry.tone === 'critical'
? 'critical'
: stayExpiry.tone === 'warning'
? 'warning'
: 'default'
}
/>
<DetailRow label={contractStart.label} value={contractStart.display} />
<DetailRow label={contractEnd.label} value={contractEnd.display} />
Expand All @@ -121,7 +137,11 @@ export function WorkerDetailPage() {
</button>
</div>
{workerDocuments.length === 0 ? (
<EmptyState kind="empty" title="제출된 서류가 없습니다" body="근로자가 서류를 제출하면 여기에 표시됩니다." />
<EmptyState
kind="empty"
title="제출된 서류가 없습니다"
body="근로자가 서류를 제출하면 여기에 표시됩니다."
/>
) : (
<div className={styles.documentList}>
{workerDocuments.map((document) => {
Expand All @@ -141,13 +161,36 @@ export function WorkerDetailPage() {
<div className={styles.sectionCard}>
<h2 className={styles.cardTitle}>안내이력</h2>
{/* TODO(#156): Audit API 연동 후 실제 활동 이력으로 대체 */}
<EmptyState kind="empty" title="안내이력 연동 준비 중입니다" body="Audit API 연동 후 표시됩니다." />
<EmptyState
kind="empty"
title="안내이력 연동 준비 중입니다"
body="Audit API 연동 후 표시됩니다."
/>
</div>

<div className={styles.sectionCard}>
<h2 className={styles.cardTitle}>현재 업무</h2>
{/* TODO(#153): Task API 연동 후 실제 진행 업무로 대체 */}
<EmptyState kind="empty" title="업무 연동 준비 중입니다" body="Task API 연동 후 표시됩니다." />
{workerTasks.length === 0 ? (
<EmptyState
kind="empty"
title="진행 중인 업무가 없습니다"
body="새 업무가 생기면 여기에 표시됩니다."
/>
) : (
<div className={styles.documentList}>
{workerTasks.map((task) => (
<Link key={task.task_id} to={`/tasks/${task.task_id}`} className={styles.documentRow}>
<span className={styles.documentName}>{task.title}</span>
<StatusLabel tone={TASK_STATUS_TONE[task.status]}>
{TASK_STATUS_LABEL[task.status]}
</StatusLabel>
<span className={styles.documentUpdatedAt}>
{task.due_date ? `~${task.due_date}` : '기한 없음'}
</span>
</Link>
))}
</div>
)}
</div>

<RegisterDocumentModal
Expand Down