From 42a31c3d41a7603df9a96698a807ae53faa655b5 Mon Sep 17 00:00:00 2001 From: mini Date: Tue, 11 Aug 2026 14:36:02 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B7=BC=EB=A1=9C=EC=9E=90=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20'=ED=98=84=EC=9E=AC=20=EC=97=85=EB=AC=B4'=20?= =?UTF-8?q?=EC=84=B9=EC=85=98=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20Task=20API?= =?UTF-8?q?=EC=97=90=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchTasks({ workerId })로 실제 업무 목록을 가져와 표시하고 task 상세로 링크한다. '안내이력' 섹션은 근로자 범위 audit 조회 API가 없어서 이번 범위에서 제외. closes #316 --- .../WorkerDetailPage.test.tsx | 111 ++++++++++++++++-- .../WorkerDetailPage/WorkerDetailPage.tsx | 63 ++++++++-- 2 files changed, 153 insertions(+), 21 deletions(-) diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx b/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx index d7a3694..6ca5b32 100644 --- a/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx +++ b/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx @@ -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' @@ -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 }, ) } @@ -55,17 +64,51 @@ function document(overrides: Partial = {}): DocumentItemRe } } -function mockWorkerAndDocuments(workerOverrides: Partial = {}, documents: DocumentItemResponse[] = []) { +function task(overrides: Partial = {}): 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 = {}, + 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 { +function registeredDocument( + overrides: Partial = {}, +): WorkerDocumentResponse { return { worker_document_id: 'D-2', worker_id: 'W-018', @@ -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)) }) } @@ -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') @@ -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 })) @@ -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 }, ), ) @@ -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) }) @@ -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') @@ -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 () => { @@ -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) }) diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.tsx b/src/pages/WorkerDetailPage/WorkerDetailPage.tsx index 9e5287d..056a8fa 100644 --- a/src/pages/WorkerDetailPage/WorkerDetailPage.tsx +++ b/src/pages/WorkerDetailPage/WorkerDetailPage.tsx @@ -1,6 +1,7 @@ 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' @@ -8,6 +9,7 @@ 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' @@ -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) @@ -89,7 +95,11 @@ export function WorkerDetailPage() {

기본정보

-
@@ -101,7 +111,13 @@ export function WorkerDetailPage() { @@ -121,7 +137,11 @@ export function WorkerDetailPage() {
{workerDocuments.length === 0 ? ( - + ) : (
{workerDocuments.map((document) => { @@ -141,13 +161,36 @@ export function WorkerDetailPage() {

안내이력

{/* TODO(#156): Audit API 연동 후 실제 활동 이력으로 대체 */} - +

현재 업무

- {/* TODO(#153): Task API 연동 후 실제 진행 업무로 대체 */} - + {workerTasks.length === 0 ? ( + + ) : ( +
+ {workerTasks.map((task) => ( + + {task.title} + + {TASK_STATUS_LABEL[task.status]} + + + {task.due_date ? `~${task.due_date}` : '기한 없음'} + + + ))} +
+ )}