From 22f7c01a1504415cd23e51d2e088b37d4c437ede Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 18 Aug 2026 18:14:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B7=BC=EB=A1=9C=EC=9E=90=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=EC=9D=B4=EB=A0=A5=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/audit.test.ts | 20 ++++- src/api/audit.ts | 29 +++++++ .../WorkerDetailPage.module.css | 76 +++++++++++++++++++ .../WorkerDetailPage.test.tsx | 60 +++++++++++++++ .../WorkerDetailPage/WorkerDetailPage.tsx | 69 +++++++++++++++-- 5 files changed, 246 insertions(+), 8 deletions(-) diff --git a/src/api/audit.test.ts b/src/api/audit.test.ts index 6693764..aa97669 100644 --- a/src/api/audit.test.ts +++ b/src/api/audit.test.ts @@ -1,8 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { fetchAuditEvents, fetchTaskActivities } from './audit' +import { fetchAuditEvents, fetchTaskActivities, fetchWorkerActivities } from './audit' function jsonResponse(body: unknown) { - return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }) + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) } beforeEach(() => { @@ -24,6 +27,19 @@ describe('fetchTaskActivities', () => { }) }) +describe('fetchWorkerActivities', () => { + it('requests a worker-scoped, cursor-based activity page', async () => { + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ items: [], next_cursor: null })) + + await fetchWorkerActivities('W-1', 'cursor-1', 30) + + const [url] = vi.mocked(fetch).mock.calls[0] + expect(url).toContain('/workers/W-1/activities?') + expect(url).toContain('limit=30') + expect(url).toContain('cursor=cursor-1') + }) +}) + describe('fetchAuditEvents', () => { it('requests /audit-events with a default limit and no other params', async () => { vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ items: [], next_cursor: null })) diff --git a/src/api/audit.ts b/src/api/audit.ts index bbde47b..6ef1f0c 100644 --- a/src/api/audit.ts +++ b/src/api/audit.ts @@ -64,11 +64,40 @@ export interface AuditPageResponse { next_cursor: string | null } +export type WorkerActivityType = + 'GUIDANCE_SENT' | 'GUIDANCE_OPENED' | 'WORKER_RESPONSE_SUBMITTED' | 'RESPONSE_REVIEWED' + +export interface WorkerActivityResponse { + activity_id: string + type: WorkerActivityType + task_id: string + task_title: string + summary: string + occurred_at: string +} + +export interface WorkerActivityPageResponse { + items: WorkerActivityResponse[] + next_cursor: string | null +} + // GET /api/v1/tasks/{taskId}/activities — VIEWER도 조회 가능한 화면용 안전 타임라인. export function fetchTaskActivities(taskId: string): Promise { return apiFetch(`/tasks/${encodeURIComponent(taskId)}/activities`) } +export function fetchWorkerActivities( + workerId: string, + cursor?: string, + limit = 20, +): Promise { + const query = new URLSearchParams({ limit: String(limit) }) + if (cursor) query.set('cursor', cursor) + return apiFetch( + `/workers/${encodeURIComponent(workerId)}/activities?${query.toString()}`, + ) +} + export interface FetchAuditEventsParams { actorType?: ActorType action?: AuditAction diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.module.css b/src/pages/WorkerDetailPage/WorkerDetailPage.module.css index 389c08b..5c2e909 100644 --- a/src/pages/WorkerDetailPage/WorkerDetailPage.module.css +++ b/src/pages/WorkerDetailPage/WorkerDetailPage.module.css @@ -125,3 +125,79 @@ font-size: 12px; color: var(--text-secondary); } + +.activityList { + display: flex; + flex-direction: column; + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.activityRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + padding: 14px 0; + border-top: 1px solid var(--border-default); +} + +.activityRow:first-child { + padding-top: 0; + border-top: 0; +} + +.activityMain { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + gap: 4px 12px; + min-width: 0; +} + +.activityType { + grid-row: span 2; + font-size: 13px; + font-weight: 700; + color: var(--brand-primary); +} + +.activitySummary { + min-width: 0; + color: var(--text-primary); + font-size: 14px; +} + +.activityTaskLink { + width: fit-content; + color: var(--text-secondary); + font-size: 12px; + text-decoration: none; +} + +.activityTaskLink:hover { + color: var(--brand-primary); + text-decoration: underline; +} + +.activityTime { + flex-shrink: 0; + color: var(--text-secondary); + font-size: 12px; +} + +@media (max-width: 640px) { + .activityRow { + flex-direction: column; + gap: 8px; + } + + .activityMain { + grid-template-columns: 1fr; + } + + .activityType { + grid-row: auto; + } +} diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx b/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx index 98904d4..0dec8f5 100644 --- a/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx +++ b/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react' 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 { WorkerActivityResponse } from '../../api/audit' import type { DocumentItemResponse, WorkerDocumentResponse } from '../../api/documents' import type { TaskSummaryResponse } from '../../api/tasks' import type { WorkerResponse } from '../../api/workers' @@ -115,9 +116,13 @@ function mockWorkerAndDocuments( workerOverrides: Partial = {}, documents: DocumentItemResponse[] = [], tasks: TaskSummaryResponse[] = [], + activities: WorkerActivityResponse[] = [], ) { vi.mocked(fetch).mockImplementation((input) => { const url = String(input) + if (url.includes('/activities')) { + return Promise.resolve(jsonResponse({ items: activities, next_cursor: null })) + } if (url.includes('/documents')) { return Promise.resolve( jsonResponse({ items: documents, page: 0, size: 100, total_elements: documents.length }), @@ -155,6 +160,9 @@ function registeredDocument( function mockWorkerError(status: number, code: string, message: string) { vi.mocked(fetch).mockImplementation((input) => { const url = String(input) + if (url.includes('/activities')) { + return Promise.resolve(jsonResponse({ items: [], next_cursor: null })) + } if (url.includes('/documents')) { return Promise.resolve(jsonResponse({ items: [], page: 0, size: 100, total_elements: 0 })) } @@ -233,10 +241,47 @@ describe('WorkerDetailPage', () => { expect(await screen.findByText('진행 중인 업무가 없습니다')).toBeInTheDocument() }) + it("shows the worker's guidance and response history with task links", async () => { + mockWorkerAndDocuments( + { display_name: '쩐티B' }, + [], + [], + [ + { + activity_id: 'A-1', + type: 'GUIDANCE_SENT', + task_id: 'T-1', + task_title: '여권 사본 요청', + summary: '근로자에게 보안 링크 안내를 전송했습니다.', + occurred_at: '2026-08-17T03:00:00Z', + }, + { + activity_id: 'A-2', + type: 'WORKER_RESPONSE_SUBMITTED', + task_id: 'T-1', + task_title: '여권 사본 요청', + summary: '근로자가 요청 서류를 제출했습니다.', + occurred_at: '2026-08-17T04:00:00Z', + }, + ], + ) + renderPage('W-018') + + expect(await screen.findByText('안내 전송')).toBeInTheDocument() + expect(screen.getByText('근로자 응답')).toBeInTheDocument() + expect(screen.getByText('근로자에게 보안 링크 안내를 전송했습니다.')).toBeInTheDocument() + const taskLinks = screen.getAllByRole('link', { name: '여권 사본 요청' }) + expect(taskLinks).toHaveLength(2) + expect(taskLinks[0]).toHaveAttribute('href', '/tasks/T-1') + }) + it('opens the urgent verification flow without declaring a legal status', async () => { const user = userEvent.setup() vi.mocked(fetch).mockImplementation((input) => { const url = String(input) + if (url.includes('/activities')) { + return Promise.resolve(jsonResponse({ items: [], next_cursor: null })) + } if (url.includes('/stay-verifications')) { return Promise.resolve(jsonResponse([stayVerification()])) } @@ -268,6 +313,9 @@ describe('WorkerDetailPage', () => { vi.mocked(fetch).mockImplementation((input, init) => { const url = String(input) const method = init?.method ?? 'GET' + if (url.includes('/activities')) { + return Promise.resolve(jsonResponse({ items: [], next_cursor: null })) + } if (url.includes('/stay-verifications/SV-1') && method === 'PATCH') { bodies.push(JSON.parse(String(init?.body)) as Record) return Promise.resolve( @@ -321,6 +369,9 @@ describe('WorkerDetailPage', () => { vi.mocked(fetch).mockImplementation((input, init) => { const url = String(input) const method = init?.method ?? 'GET' + if (url.includes('/activities')) { + return Promise.resolve(jsonResponse({ items: [], next_cursor: null })) + } if (url.includes('/stay-verifications/SV-1') && method === 'PATCH') { return Promise.resolve( jsonResponse( @@ -393,6 +444,9 @@ describe('WorkerDetailPage', () => { vi.mocked(fetch).mockImplementation((input, init) => { const url = String(input) const method = init?.method ?? 'GET' + if (url.includes('/activities')) { + return Promise.resolve(jsonResponse({ items: [], next_cursor: null })) + } if (url.includes('/documents') && method === 'GET') { documentsGetCount += 1 const items = @@ -432,6 +486,9 @@ describe('WorkerDetailPage', () => { vi.mocked(fetch).mockImplementation((input, init) => { const url = String(input) const method = init?.method ?? 'GET' + if (url.includes('/activities')) { + return Promise.resolve(jsonResponse({ items: [], next_cursor: null })) + } calls.push({ url, method }) if (url.includes('/documents') && method === 'GET') { return Promise.resolve(jsonResponse({ items: [], page: 0, size: 100, total_elements: 0 })) @@ -482,6 +539,9 @@ describe('WorkerDetailPage', () => { vi.mocked(fetch).mockImplementation((input, init) => { const url = String(input) const method = init?.method ?? 'GET' + if (url.includes('/activities')) { + return Promise.resolve(jsonResponse({ items: [], next_cursor: null })) + } calls.push({ url, method, body: init?.body as string | undefined }) if (url.includes('/documents')) { return Promise.resolve(jsonResponse({ items: [], page: 0, size: 100, total_elements: 0 })) diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.tsx b/src/pages/WorkerDetailPage/WorkerDetailPage.tsx index 5aeb4c9..3a97b50 100644 --- a/src/pages/WorkerDetailPage/WorkerDetailPage.tsx +++ b/src/pages/WorkerDetailPage/WorkerDetailPage.tsx @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react' import { Link, useParams } from 'react-router-dom' +import { fetchWorkerActivities, type WorkerActivityType } from '../../api/audit' import { fetchDocuments } from '../../api/documents' import { fetchTasks } from '../../api/tasks' import { fetchWorkerById } from '../../api/workers' @@ -10,12 +11,20 @@ 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 { formatEventTime } from '../../utils/datetime' import { getDocumentViewModel } from '../../view-models/documentViewModel' import { getOperationalDateViewModel } from '../../view-models/dateViewModel' import { RegisterDocumentModal } from './overlays/RegisterDocumentModal' import { StayVerificationModal } from './overlays/StayVerificationModal' import styles from './WorkerDetailPage.module.css' +const WORKER_ACTIVITY_LABEL: Record = { + GUIDANCE_SENT: '안내 전송', + GUIDANCE_OPENED: '근로자 확인', + WORKER_RESPONSE_SUBMITTED: '근로자 응답', + RESPONSE_REVIEWED: '담당자 확인', +} + export function WorkerDetailPage() { const { workerId } = useParams() @@ -32,6 +41,17 @@ export function WorkerDetailPage() { ) const workerTasks = taskPage?.items ?? [] + const { + status: activityStatus, + data: activityPage, + error: activityError, + refetch: refetchActivities, + } = useApiQuery( + useCallback(() => fetchWorkerActivities(workerId ?? '', undefined, 20), [workerId]), + useCallback((page: { items: unknown[] }) => page.items.length === 0, []), + ) + const workerActivities = activityPage?.items ?? [] + const [registerModalOpen, setRegisterModalOpen] = useState(false) const [editModalOpen, setEditModalOpen] = useState(false) const [stayVerificationOpen, setStayVerificationOpen] = useState(false) @@ -175,12 +195,49 @@ export function WorkerDetailPage() {

안내이력

- {/* TODO(#156): Audit API 연동 후 실제 활동 이력으로 대체 */} - + {activityStatus === 'loading' && ( + + )} + {activityStatus === 'error' && ( + + )} + {activityStatus === 'empty' && ( + + )} + {activityStatus === 'success' && ( +
    + {workerActivities.map((activity) => ( +
  1. +
    + + {WORKER_ACTIVITY_LABEL[activity.type]} + + {activity.summary} + + {activity.task_title} + +
    + +
  2. + ))} +
+ )}