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
20 changes: 18 additions & 2 deletions src/api/audit.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -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 }))
Expand Down
29 changes: 29 additions & 0 deletions src/api/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuditEventResponse[]> {
return apiFetch<AuditEventResponse[]>(`/tasks/${encodeURIComponent(taskId)}/activities`)
}

export function fetchWorkerActivities(
workerId: string,
cursor?: string,
limit = 20,
): Promise<WorkerActivityPageResponse> {
const query = new URLSearchParams({ limit: String(limit) })
if (cursor) query.set('cursor', cursor)
return apiFetch<WorkerActivityPageResponse>(
`/workers/${encodeURIComponent(workerId)}/activities?${query.toString()}`,
)
}

export interface FetchAuditEventsParams {
actorType?: ActorType
action?: AuditAction
Expand Down
76 changes: 76 additions & 0 deletions src/pages/WorkerDetailPage/WorkerDetailPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
60 changes: 60 additions & 0 deletions src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -115,9 +116,13 @@ function mockWorkerAndDocuments(
workerOverrides: Partial<WorkerResponse> = {},
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 }),
Expand Down Expand Up @@ -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 }))
}
Expand Down Expand Up @@ -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()]))
}
Expand Down Expand Up @@ -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<string, unknown>)
return Promise.resolve(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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 }))
Expand Down Expand Up @@ -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 }))
Expand Down
69 changes: 63 additions & 6 deletions src/pages/WorkerDetailPage/WorkerDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<WorkerActivityType, string> = {
GUIDANCE_SENT: '안내 전송',
GUIDANCE_OPENED: '근로자 확인',
WORKER_RESPONSE_SUBMITTED: '근로자 응답',
RESPONSE_REVIEWED: '담당자 확인',
}

export function WorkerDetailPage() {
const { workerId } = useParams()

Expand All @@ -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)
Expand Down Expand Up @@ -175,12 +195,49 @@ export function WorkerDetailPage() {

<div className={styles.sectionCard}>
<h2 className={styles.cardTitle}>안내이력</h2>
{/* TODO(#156): Audit API 연동 후 실제 활동 이력으로 대체 */}
<EmptyState
kind="empty"
title="안내이력 연동 준비 중입니다"
body="Audit API 연동 후 표시됩니다."
/>
{activityStatus === 'loading' && (
<EmptyState
kind="loading"
title="안내이력을 불러오는 중입니다"
body="근로자에게 전달한 안내와 회신 기록을 확인하고 있습니다."
/>
)}
{activityStatus === 'error' && (
<EmptyState
kind="error"
title="안내이력을 불러오지 못했습니다"
body={activityError ? getErrorMessage(activityError) : '잠시 후 다시 시도해 주세요.'}
actionLabel="다시 시도"
onAction={refetchActivities}
/>
)}
{activityStatus === 'empty' && (
<EmptyState
kind="empty"
title="안내이력이 없습니다"
body="안내를 보내거나 근로자가 응답하면 시간순으로 표시됩니다."
/>
)}
{activityStatus === 'success' && (
<ol className={styles.activityList}>
{workerActivities.map((activity) => (
<li key={activity.activity_id} className={styles.activityRow}>
<div className={styles.activityMain}>
<span className={styles.activityType}>
{WORKER_ACTIVITY_LABEL[activity.type]}
</span>
<span className={styles.activitySummary}>{activity.summary}</span>
<Link to={`/tasks/${activity.task_id}`} className={styles.activityTaskLink}>
{activity.task_title}
</Link>
</div>
<time className={styles.activityTime} dateTime={activity.occurred_at}>
{formatEventTime(activity.occurred_at)}
</time>
</li>
))}
</ol>
)}
</div>

<div className={styles.sectionCard}>
Expand Down