diff --git a/src/api/approvals.test.ts b/src/api/approvals.test.ts
index 3f6a801..c6a48c8 100644
--- a/src/api/approvals.test.ts
+++ b/src/api/approvals.test.ts
@@ -4,6 +4,7 @@ import {
approveTask,
buildTaskApprovalSnapshot,
completeTask,
+ recordExternalSubmission,
recordTaskEvidence,
rejectTask,
requestTaskApproval,
@@ -18,7 +19,7 @@ function jsonResponse(body: unknown, status = 200) {
function task(): TaskDetailResponse {
return {
- task_id: 'T-1', worker_id: 'W-1', case_id: null, task_type: 'STAY_PERIOD_EXTENSION',
+ task_id: 'T-1', target_type: 'WORKER', worker_id: 'W-1', case_id: null, task_type: 'STAY_PERIOD_EXTENSION',
workflow_id: 'wf-stay', workflow_catalog_version: '3', title: '체류기간 연장', description: '안내',
business_data: { office: '수원' }, source: 'MANUAL', status: 'DRAFT', due_date: '2026-08-10',
content_revision: 2, version: 7, missing_required_slots: [], checklist_items: [], created_by: 'U-1',
@@ -35,7 +36,7 @@ describe('approval APIs', () => {
expected_version: 7,
ai_snapshot: null,
hr_snapshot: {
- worker_id: 'W-1', task_type: 'STAY_PERIOD_EXTENSION', workflow_id: 'wf-stay',
+ target_type: 'WORKER', worker_id: 'W-1', task_type: 'STAY_PERIOD_EXTENSION', workflow_id: 'wf-stay',
title: '체류기간 연장', description: '안내', due_date: '2026-08-10', business_data: { office: '수원' },
},
changed_fields: ['task_content'],
@@ -43,12 +44,17 @@ describe('approval APIs', () => {
})
})
- it('uses the approval, decision, evidence and completion endpoints', async () => {
+ it('uses the approval, decision, external submission, evidence and completion endpoints', async () => {
vi.mocked(fetch).mockImplementation(() => Promise.resolve(jsonResponse({ task_id: 'T-1' }, 201)))
await requestTaskApproval('T-1', buildTaskApprovalSnapshot(task()))
await approveTask('T-1', { expected_version: 8 })
await rejectTask('T-1', { expected_version: 8, reason: '마감일 확인 필요' })
+ await recordExternalSubmission('T-1', {
+ expected_version: 8,
+ destination: '수원출입국·외국인청',
+ safe_reference: '접수번호 1234',
+ })
await recordTaskEvidence('T-1', { evidence_type: 'RECEIPT', note: '접수번호 1234' })
await completeTask('T-1', 9)
@@ -56,8 +62,14 @@ describe('approval APIs', () => {
expect(String(calls[0][0])).toContain('/tasks/T-1/approval-requests')
expect(String(calls[1][0])).toContain('/tasks/T-1/approve')
expect(String(calls[2][0])).toContain('/tasks/T-1/reject')
- expect(String(calls[3][0])).toContain('/tasks/T-1/evidence')
- expect(String(calls[4][0])).toContain('/tasks/T-1/complete')
- expect(JSON.parse(calls[4][1]?.body as string)).toEqual({ expected_version: 9 })
+ expect(String(calls[3][0])).toContain('/tasks/T-1/external-submissions')
+ expect(JSON.parse(calls[3][1]?.body as string)).toEqual({
+ expected_version: 8,
+ destination: '수원출입국·외국인청',
+ safe_reference: '접수번호 1234',
+ })
+ expect(String(calls[4][0])).toContain('/tasks/T-1/evidence')
+ expect(String(calls[5][0])).toContain('/tasks/T-1/complete')
+ expect(JSON.parse(calls[5][1]?.body as string)).toEqual({ expected_version: 9 })
})
})
diff --git a/src/api/approvals.ts b/src/api/approvals.ts
index 1df9ca2..14293d1 100644
--- a/src/api/approvals.ts
+++ b/src/api/approvals.ts
@@ -36,6 +36,13 @@ export interface RecordTaskEvidenceBody {
recorded_at?: string
}
+export interface RecordExternalSubmissionBody {
+ expected_version: number
+ destination: string
+ safe_reference: string
+ submitted_at?: string
+}
+
export interface TaskActionResponse {
resource_id: string
task_id: string
@@ -48,6 +55,7 @@ export function buildTaskApprovalSnapshot(task: TaskDetailResponse): RequestTask
expected_version: task.version,
ai_snapshot: null,
hr_snapshot: {
+ target_type: task.target_type,
worker_id: task.worker_id,
task_type: task.task_type,
workflow_id: task.workflow_id,
@@ -104,6 +112,16 @@ export function recordTaskEvidence(
})
}
+export function recordExternalSubmission(
+ taskId: string,
+ body: RecordExternalSubmissionBody,
+): Promise {
+ return apiFetch(
+ `/tasks/${encodeURIComponent(taskId)}/external-submissions`,
+ { method: 'POST', body: JSON.stringify(body) },
+ )
+}
+
export function completeTask(taskId: string, expectedVersion: number): Promise {
return apiFetch(`/tasks/${encodeURIComponent(taskId)}/complete`, {
method: 'POST',
diff --git a/src/api/audit.ts b/src/api/audit.ts
index c56685a..bbde47b 100644
--- a/src/api/audit.ts
+++ b/src/api/audit.ts
@@ -15,8 +15,34 @@ export type AuditAction =
| 'EXTERNAL_SUBMISSION_RECORDED'
| 'EVIDENCE_RECORDED'
| 'TASK_COMPLETED'
+ | 'FILE_UPLOADED'
+ | 'FILE_DOWNLOADED'
+ | 'WORKER_DOCUMENT_FILE_LINKED'
+ | 'DOCUMENT_REQUEST_DRAFT_SAVED'
+ | 'AI_RUN_CREATED'
+ | 'AI_RUN_ANSWERS_SUBMITTED'
+ | 'AI_RUN_CANDIDATES_DECIDED'
+ | 'OUTBOX_MANUAL_RETRY_REQUESTED'
+ | 'WORKER_LINK_RESPONSE_SUBMITTED'
+ | 'WORKER_LINK_RESPONSES_REVIEWED'
+ | 'WORKER_LINK_SENT'
+ | 'WORKER_LINK_ACCESSED'
+ | 'USER_AGREEMENTS_RECORDED'
+ | 'PASSWORD_RESET_REQUESTED'
+ | 'PASSWORD_RESET_COMPLETED'
-export type AuditTargetType = 'TASK' | 'APPROVAL_REQUEST' | 'EXTERNAL_SUBMISSION' | 'EVIDENCE'
+export type AuditTargetType =
+ | 'TASK'
+ | 'APPROVAL_REQUEST'
+ | 'EXTERNAL_SUBMISSION'
+ | 'EVIDENCE'
+ | 'FILE'
+ | 'WORKER_DOCUMENT'
+ | 'DOCUMENT_REQUEST_DRAFT'
+ | 'AI_RUN'
+ | 'OUTBOX_EVENT'
+ | 'WORKER_LINK'
+ | 'USER_ACCOUNT'
export interface AuditEventResponse {
audit_event_id: string
diff --git a/src/api/cases.test.ts b/src/api/cases.test.ts
new file mode 100644
index 0000000..baa9770
--- /dev/null
+++ b/src/api/cases.test.ts
@@ -0,0 +1,27 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { fetchCaseProjection, fetchCases } from './cases'
+
+function jsonResponse(body: unknown) {
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+beforeEach(() => vi.stubGlobal('fetch', vi.fn()))
+afterEach(() => vi.unstubAllGlobals())
+
+describe('case APIs', () => {
+ it('lists Cases with query parameters and fetches a projection by encoded ID', async () => {
+ vi.mocked(fetch).mockImplementation(() =>
+ Promise.resolve(jsonResponse({ items: [], page: 0, size: 20, total_elements: 0 })),
+ )
+
+ await fetchCases({ keyword: '응웬 반', page: 1, size: 20 })
+ await fetchCaseProjection('C/1')
+
+ const calls = vi.mocked(fetch).mock.calls
+ expect(String(calls[0][0])).toContain('/cases?keyword=%EC%9D%91%EC%9B%AC+%EB%B0%98&page=1&size=20')
+ expect(String(calls[1][0])).toContain('/cases/C%2F1/projection')
+ })
+})
diff --git a/src/api/tasks.test.ts b/src/api/tasks.test.ts
index 3b127c4..ec2c8b2 100644
--- a/src/api/tasks.test.ts
+++ b/src/api/tasks.test.ts
@@ -32,6 +32,8 @@ describe('fetchTasks', () => {
await fetchTasks({
status: 'READY_FOR_REVIEW',
+ targetType: 'COMPANY',
+ source: 'SYSTEM_DDAY',
caseId: 'CASE-17',
keyword: '체류연장',
page: 1,
@@ -40,7 +42,10 @@ describe('fetchTasks', () => {
const [url] = vi.mocked(fetch).mock.calls[0]
expect(url).toContain('status=READY_FOR_REVIEW')
- expect(url).toContain('caseId=CASE-17')
+ expect(url).toContain('target_type=COMPANY')
+ expect(url).toContain('source=SYSTEM_DDAY')
+ expect(url).toContain('case_id=CASE-17')
+ expect(url).not.toContain('caseId=')
expect(url).toContain('keyword=%EC%B2%B4%EB%A5%98%EC%97%B0%EC%9E%A5')
expect(url).toContain('page=1&size=20')
})
@@ -77,6 +82,25 @@ describe('createTask', () => {
title: '체류연장 준비',
})
})
+
+ it('supports a company task without worker_id', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ task_id: 'T-company' }, 201))
+
+ await createTask({
+ target_type: 'COMPANY',
+ task_type: 'PAYROLL_EXPLANATION',
+ workflow_id: 'wf-payroll-explanation',
+ title: '급여명세서 설명 준비',
+ })
+
+ const [, init] = vi.mocked(fetch).mock.calls[0]
+ expect(JSON.parse(init?.body as string)).toEqual({
+ target_type: 'COMPANY',
+ task_type: 'PAYROLL_EXPLANATION',
+ workflow_id: 'wf-payroll-explanation',
+ title: '급여명세서 설명 준비',
+ })
+ })
})
describe('updateTask', () => {
diff --git a/src/api/tasks.ts b/src/api/tasks.ts
index 6130a19..33de58e 100644
--- a/src/api/tasks.ts
+++ b/src/api/tasks.ts
@@ -11,7 +11,16 @@ export type TaskStatus =
| 'COMPLETED'
| 'CANCELLED'
-export type TaskType = 'RECONTRACT' | 'EMPLOYMENT_PERIOD_EXTENSION' | 'STAY_PERIOD_EXTENSION'
+export type TaskType =
+ | 'RECONTRACT'
+ | 'EMPLOYMENT_PERIOD_EXTENSION'
+ | 'STAY_PERIOD_EXTENSION'
+ | 'DOCUMENT_REQUEST'
+ | 'WORKER_ONBOARDING'
+ | 'PAYROLL_EXPLANATION'
+ | 'EMPLOYMENT_CHANGE'
+ | 'WORK_INSTRUCTION'
+export type TaskTargetType = 'WORKER' | 'COMPANY'
export type TaskSource = 'MANUAL' | 'SYSTEM_DDAY' | 'AI_CANDIDATE'
export interface TaskChecklistItemResponse {
@@ -27,7 +36,8 @@ export interface TaskChecklistItemResponse {
export interface TaskDetailResponse {
task_id: string
- worker_id: string
+ target_type: TaskTargetType
+ worker_id: string | null
case_id: string | null
task_type: TaskType
workflow_id: string
@@ -50,7 +60,8 @@ export interface TaskDetailResponse {
export interface TaskSummaryResponse {
task_id: string
- worker_id: string
+ target_type: TaskTargetType
+ worker_id: string | null
case_id: string | null
task_type: TaskType
workflow_id: string
@@ -76,6 +87,8 @@ export interface TaskPageResponse {
export interface FetchTasksParams {
status?: TaskStatus
taskType?: TaskType
+ targetType?: TaskTargetType
+ source?: TaskSource
workerId?: string
caseId?: string
dueFrom?: string
@@ -89,8 +102,10 @@ export function fetchTasks(params: FetchTasksParams = {}): Promise {
return apiFetch(`/tasks/${encodeURIComponent(taskId)}`)
}
-export interface CreateTaskBody {
- worker_id: string
+interface CreateTaskFields {
case_id?: string
task_type: TaskType
workflow_id: string
@@ -114,6 +128,12 @@ export interface CreateTaskBody {
business_data?: Record
}
+export type CreateTaskBody = CreateTaskFields &
+ (
+ | { target_type?: 'WORKER'; worker_id: string }
+ | { target_type: 'COMPANY'; worker_id?: never }
+ )
+
export function createTask(body: CreateTaskBody): Promise {
return apiFetch('/tasks', { method: 'POST', body: JSON.stringify(body) })
}
diff --git a/src/api/workerLinks.test.ts b/src/api/workerLinks.test.ts
index de7590b..957c620 100644
--- a/src/api/workerLinks.test.ts
+++ b/src/api/workerLinks.test.ts
@@ -1,7 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
+ fetchTaskWorkerLinkDelivery,
+ fetchTaskWorkerResponses,
fetchWorkerLink,
issueWorkerLink,
+ markWorkerLinkSent,
+ markTaskWorkerResponsesRead,
resolveWorkerPortalUrl,
submitWorkerResponse,
uploadWorkerLinkDocument,
@@ -24,6 +28,37 @@ describe('worker link APIs', () => {
expect(new Headers(init?.headers).get('Idempotency-Key')).toBe('issue-1')
})
+ it('gets the current delivery status and records manual delivery', async () => {
+ vi.mocked(fetch)
+ .mockResolvedValueOnce(
+ jsonResponse({
+ worker_link_id: 'L-1',
+ link_status: 'ACTIVE',
+ delivery_status: 'NOT_SENT',
+ sent_at: null,
+ expires_at: '2026-08-07T00:00:00Z',
+ }),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ worker_link_id: 'L-1',
+ link_status: 'ACTIVE',
+ delivery_status: 'SENT',
+ sent_at: '2026-08-05T00:00:00Z',
+ expires_at: '2026-08-07T00:00:00Z',
+ }),
+ )
+
+ await fetchTaskWorkerLinkDelivery('T/1')
+ await markWorkerLinkSent('L/1')
+
+ const calls = vi.mocked(fetch).mock.calls
+ expect(String(calls[0][0])).toContain('/tasks/T%2F1/worker-link')
+ expect(calls[0][1]?.method).toBeUndefined()
+ expect(String(calls[1][0])).toContain('/worker-links/L%2F1/sent')
+ expect(calls[1][1]?.method).toBe('POST')
+ })
+
it('views, uploads and submits through the public token endpoints', async () => {
vi.mocked(fetch).mockImplementation(() => Promise.resolve(jsonResponse({ upload_id: 'U-1' }, 201)))
@@ -40,6 +75,23 @@ describe('worker link APIs', () => {
expect(String(calls[2][0])).toContain('/responses')
})
+ it('lists and marks HR worker responses as reviewed through authenticated endpoints', async () => {
+ vi.mocked(fetch)
+ .mockResolvedValueOnce(
+ jsonResponse({ items: [], page: 1, size: 10, total_elements: 0, total_pages: 0 }),
+ )
+ .mockResolvedValueOnce(new Response(null, { status: 204 }))
+
+ await fetchTaskWorkerResponses('T/1', 1, 10)
+ await markTaskWorkerResponsesRead('T/1')
+
+ const calls = vi.mocked(fetch).mock.calls
+ expect(String(calls[0][0])).toContain('/tasks/T%2F1/worker-responses?page=1&size=10')
+ expect(calls[0][1]?.method).toBeUndefined()
+ expect(String(calls[1][0])).toContain('/tasks/T%2F1/worker-responses/read')
+ expect(calls[1][1]?.method).toBe('POST')
+ })
+
it('turns the current backend raw-token response into a frontend route', () => {
expect(resolveWorkerPortalUrl('raw/token', 'https://fowoco.kr')).toBe(
'https://fowoco.kr/worker-portal/raw%2Ftoken',
diff --git a/src/api/workerLinks.ts b/src/api/workerLinks.ts
index b4e5fde..e041452 100644
--- a/src/api/workerLinks.ts
+++ b/src/api/workerLinks.ts
@@ -13,7 +13,22 @@ export interface WorkerLinkIssueBody {
}
export interface WorkerLinkIssueResponse {
- worker_url: string
+ worker_link_id: string
+ worker_url: string | null
+ expires_at: string
+ delivery_status: WorkerLinkDeliveryStatus
+ sent_at: string | null
+ already_issued: boolean
+}
+
+export type WorkerLinkStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED'
+export type WorkerLinkDeliveryStatus = 'NOT_SENT' | 'SENT'
+
+export interface WorkerLinkDeliveryResponse {
+ worker_link_id: string
+ link_status: WorkerLinkStatus
+ delivery_status: WorkerLinkDeliveryStatus
+ sent_at: string | null
expires_at: string
}
@@ -42,6 +57,26 @@ export interface WorkerResponseSubmitResponse {
received_at: string
}
+export type WorkerConversationStatus = 'WAITING_WORKER' | 'NEEDS_FOLLOWUP' | 'REOPENED'
+
+export interface WorkerResponseItemResponse {
+ response_id: string
+ response_type: WorkerResponseType
+ message: string | null
+ upload_ids: string[]
+ conversation_status: WorkerConversationStatus
+ unread: boolean
+ received_at: string
+}
+
+export interface WorkerResponsePageResponse {
+ items: WorkerResponseItemResponse[]
+ page: number
+ size: number
+ total_elements: number
+ total_pages: number
+}
+
export function issueWorkerLink(
taskId: string,
body: WorkerLinkIssueBody,
@@ -54,6 +89,21 @@ export function issueWorkerLink(
})
}
+export function fetchTaskWorkerLinkDelivery(
+ taskId: string,
+): Promise {
+ return apiFetch(
+ `/tasks/${encodeURIComponent(taskId)}/worker-link`,
+ )
+}
+
+export function markWorkerLinkSent(workerLinkId: string): Promise {
+ return apiFetch(
+ `/worker-links/${encodeURIComponent(workerLinkId)}/sent`,
+ { method: 'POST' },
+ )
+}
+
export function fetchWorkerLink(token: string): Promise {
return apiFetch(`/public/worker-links/${encodeURIComponent(token)}`, {
skipAuthRetry: true,
@@ -96,6 +146,24 @@ export function submitWorkerResponse(
)
}
+export function fetchTaskWorkerResponses(
+ taskId: string,
+ page = 0,
+ size = 20,
+): Promise {
+ const query = new URLSearchParams({ page: String(page), size: String(size) })
+ return apiFetch(
+ `/tasks/${encodeURIComponent(taskId)}/worker-responses?${query.toString()}`,
+ )
+}
+
+export function markTaskWorkerResponsesRead(taskId: string): Promise {
+ return apiFetch(
+ `/tasks/${encodeURIComponent(taskId)}/worker-responses/read`,
+ { method: 'POST' },
+ )
+}
+
export function resolveWorkerPortalUrl(workerUrlOrToken: string, origin: string): string {
if (/^https?:\/\//i.test(workerUrlOrToken)) return workerUrlOrToken
if (workerUrlOrToken.startsWith('/')) return new URL(workerUrlOrToken, origin).toString()
diff --git a/src/api/workers.test.ts b/src/api/workers.test.ts
index 53e23ca..dc480f9 100644
--- a/src/api/workers.test.ts
+++ b/src/api/workers.test.ts
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { fetchWorkerById, fetchWorkers } from './workers'
+import { fetchWorkerById, fetchWorkers, patchWorker, registerWorker } from './workers'
function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), {
@@ -49,3 +49,41 @@ describe('fetchWorkerById', () => {
expect(url).toContain('/workers/W-1')
})
})
+
+describe('worker E-9 fields', () => {
+ it('sends visa and employment dates when registering a worker', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ worker_id: 'W-2' }))
+
+ await registerWorker({
+ display_name: '응웬반A',
+ visa_type: 'E-9',
+ employment_permit_end_date: '2028-03-01',
+ employment_activity_end_date: '2028-03-01',
+ })
+
+ const [, init] = vi.mocked(fetch).mock.calls[0]
+ expect(JSON.parse(init?.body as string)).toEqual({
+ display_name: '응웬반A',
+ visa_type: 'E-9',
+ employment_permit_end_date: '2028-03-01',
+ employment_activity_end_date: '2028-03-01',
+ })
+ })
+
+ it('keeps expected_version when patching employment dates', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ worker_id: 'W-2' }))
+
+ await patchWorker('W-2', {
+ employment_permit_end_date: '2028-04-01',
+ expected_version: 3,
+ })
+
+ const [url, init] = vi.mocked(fetch).mock.calls[0]
+ expect(url).toContain('/workers/W-2')
+ expect(init?.method).toBe('PATCH')
+ expect(JSON.parse(init?.body as string)).toEqual({
+ employment_permit_end_date: '2028-04-01',
+ expected_version: 3,
+ })
+ })
+})
diff --git a/src/api/workers.ts b/src/api/workers.ts
index bc31aeb..1f43735 100644
--- a/src/api/workers.ts
+++ b/src/api/workers.ts
@@ -10,9 +10,12 @@ export interface WorkerResponse {
nationality_code: string
preferred_language: string
work_status: WorkStatus
+ visa_type: string | null
stay_expiry_date: string | null
contract_start_date: string | null
contract_end_date: string | null
+ employment_permit_end_date: string | null
+ employment_activity_end_date: string | null
created_at: string
updated_at: string
version: number
@@ -54,9 +57,12 @@ export interface WorkerCreateBody {
display_name: string
nationality_code?: string
preferred_language?: string
+ visa_type?: string
stay_expiry_date?: string
contract_start_date?: string
contract_end_date?: string
+ employment_permit_end_date?: string
+ employment_activity_end_date?: string
}
export function registerWorker(body: WorkerCreateBody): Promise {
@@ -68,9 +74,12 @@ export interface WorkerPatchBody {
nationality_code?: string
preferred_language?: string
work_status?: WorkStatus
+ visa_type?: string
stay_expiry_date?: string
contract_start_date?: string
contract_end_date?: string
+ employment_permit_end_date?: string
+ employment_activity_end_date?: string
expected_version: number
}
diff --git a/src/components/worker/WorkerFormModal.tsx b/src/components/worker/WorkerFormModal.tsx
index 182aad2..f54b9e1 100644
--- a/src/components/worker/WorkerFormModal.tsx
+++ b/src/components/worker/WorkerFormModal.tsx
@@ -38,14 +38,21 @@ function errorMessageOf(error: unknown, fallback: string): string {
return fallback
}
+function clearedExistingField(original: string | null | undefined, current: string): boolean {
+ return Boolean(original) && current === ''
+}
+
export function WorkerFormModal({ open, mode, worker, onClose, onSaved }: WorkerFormModalProps) {
const [displayName, setDisplayName] = useState('')
const [nationalityCode, setNationalityCode] = useState('')
const [preferredLanguage, setPreferredLanguage] = useState('')
const [workStatus, setWorkStatus] = useState('ACTIVE')
+ const [visaType, setVisaType] = useState('E-9')
const [stayExpiryDate, setStayExpiryDate] = useState('')
const [contractStartDate, setContractStartDate] = useState('')
const [contractEndDate, setContractEndDate] = useState('')
+ const [employmentPermitEndDate, setEmploymentPermitEndDate] = useState('')
+ const [employmentActivityEndDate, setEmploymentActivityEndDate] = useState('')
const [submitting, setSubmitting] = useState(false)
const [errorMessage, setErrorMessage] = useState(null)
@@ -55,9 +62,12 @@ export function WorkerFormModal({ open, mode, worker, onClose, onSaved }: Worker
setNationalityCode(worker?.nationality_code ?? '')
setPreferredLanguage(worker?.preferred_language ?? '')
setWorkStatus(worker?.work_status ?? 'ACTIVE')
+ setVisaType(worker?.visa_type ?? 'E-9')
setStayExpiryDate(worker?.stay_expiry_date ?? '')
setContractStartDate(worker?.contract_start_date ?? '')
setContractEndDate(worker?.contract_end_date ?? '')
+ setEmploymentPermitEndDate(worker?.employment_permit_end_date ?? '')
+ setEmploymentActivityEndDate(worker?.employment_activity_end_date ?? '')
setErrorMessage(null)
}, [open, worker])
@@ -71,6 +81,25 @@ export function WorkerFormModal({ open, mode, worker, onClose, onSaved }: Worker
setErrorMessage('표시 이름을 입력해 주세요.')
return
}
+ if (contractStartDate && contractEndDate && contractEndDate < contractStartDate) {
+ setErrorMessage('계약 종료일은 계약 시작일보다 빠를 수 없습니다.')
+ return
+ }
+ if (
+ mode === 'edit' &&
+ worker &&
+ [
+ [worker.visa_type, visaType],
+ [worker.stay_expiry_date, stayExpiryDate],
+ [worker.contract_start_date, contractStartDate],
+ [worker.contract_end_date, contractEndDate],
+ [worker.employment_permit_end_date, employmentPermitEndDate],
+ [worker.employment_activity_end_date, employmentActivityEndDate],
+ ].some(([original, current]) => clearedExistingField(original, current ?? ''))
+ ) {
+ setErrorMessage('등록된 비자·날짜 값의 삭제는 아직 지원하지 않습니다. 기존 값을 유지해 주세요.')
+ return
+ }
setSubmitting(true)
setErrorMessage(null)
@@ -81,18 +110,24 @@ export function WorkerFormModal({ open, mode, worker, onClose, onSaved }: Worker
display_name: displayName.trim(),
nationality_code: nationalityCode || undefined,
preferred_language: preferredLanguage || undefined,
+ visa_type: visaType || undefined,
stay_expiry_date: stayExpiryDate || undefined,
contract_start_date: contractStartDate || undefined,
contract_end_date: contractEndDate || undefined,
+ employment_permit_end_date: employmentPermitEndDate || undefined,
+ employment_activity_end_date: employmentActivityEndDate || undefined,
})
: await patchWorker(worker!.worker_id, {
display_name: displayName.trim(),
nationality_code: nationalityCode || undefined,
preferred_language: preferredLanguage || undefined,
work_status: workStatus,
+ visa_type: visaType || undefined,
stay_expiry_date: stayExpiryDate || undefined,
contract_start_date: contractStartDate || undefined,
contract_end_date: contractEndDate || undefined,
+ employment_permit_end_date: employmentPermitEndDate || undefined,
+ employment_activity_end_date: employmentActivityEndDate || undefined,
expected_version: worker!.version,
})
onSaved(saved)
@@ -135,6 +170,7 @@ export function WorkerFormModal({ open, mode, worker, onClose, onSaved }: Worker
setPreferredLanguage(event.target.value)}
placeholder="vi"
@@ -158,10 +194,22 @@ export function WorkerFormModal({ open, mode, worker, onClose, onSaved }: Worker
>
)}
+ 비자 유형
+ setVisaType(event.target.value)}
+ placeholder="E-9"
+ maxLength={20}
+ />
+
체류 만료일
setStayExpiryDate(event.target.value)}
/>
@@ -184,6 +232,24 @@ export function WorkerFormModal({ open, mode, worker, onClose, onSaved }: Worker
/>
+ 고용 관련 종료일
+
+ setEmploymentPermitEndDate(event.target.value)}
+ />
+ setEmploymentActivityEndDate(event.target.value)}
+ />
+
+
{errorMessage && {errorMessage}
}
diff --git a/src/pages/AgentLogPage/AgentLogPage.tsx b/src/pages/AgentLogPage/AgentLogPage.tsx
index 2a6e36d..8e1e56c 100644
--- a/src/pages/AgentLogPage/AgentLogPage.tsx
+++ b/src/pages/AgentLogPage/AgentLogPage.tsx
@@ -7,7 +7,7 @@ import { Dropdown } from '../../components/ui/Dropdown/Dropdown'
import { EmptyState } from '../../components/ui/EmptyState/EmptyState'
import { ListRow } from '../../components/ui/ListRow/ListRow'
import { useApiQuery } from '../../hooks/useApiQuery'
-import { ACTOR_TYPE_TO_AGENT_SOURCE, AUDIT_ACTION_LABEL } from '../../utils/auditLabels'
+import { ACTOR_TYPE_TO_AGENT_SOURCE, getAuditActionLabel } from '../../utils/auditLabels'
import { formatEventTime } from '../../utils/datetime'
import styles from './AgentLogPage.module.css'
@@ -123,7 +123,7 @@ export function AgentLogPage() {
{logs.map((log) => (
{formatEventTime(log.created_at)}
- {log.change_summary ?? AUDIT_ACTION_LABEL[log.action]}
+ {log.change_summary ?? getAuditActionLabel(log.action)}
{log.target_type === 'TASK' ? (
= {}): TaskDetailResponse {
return {
task_id: 'T-1',
+ target_type: 'WORKER',
worker_id: 'W-1',
case_id: null,
task_type: 'STAY_PERIOD_EXTENSION',
@@ -119,17 +126,101 @@ function documentsResponse(items: DocumentItemResponse[] = []): DocumentPageResp
return { items, page: 0, size: 100, total_elements: items.length }
}
+function caseProjectionResponse(
+ overrides: Partial = {},
+): CaseProjectionResponse {
+ return {
+ case_id: 'C-1',
+ worker_id: 'W-1',
+ worker_display_name: '응웬반A',
+ title: '체류기간 연장',
+ display_status: 'REVIEW_REQUIRED',
+ has_unread_response: false,
+ priority: 'HIGH',
+ progress: { completed_steps: 1, total_steps: 2, percentage: 50 },
+ due_date: '2026-08-01',
+ current_task: {
+ task_id: 'T-1',
+ task_type: 'STAY_PERIOD_EXTENSION',
+ title: '응웬반A 체류연장 준비',
+ status: 'READY_FOR_REVIEW',
+ due_date: '2026-08-01',
+ },
+ updated_at: '2026-07-20T00:00:00Z',
+ lifecycle_status: 'ACTIVE',
+ readiness: {
+ completed_checklist_items: 1,
+ total_checklist_items: 2,
+ verified_documents: 2,
+ total_documents: 3,
+ pending_approvals: 1,
+ approved_approvals: 0,
+ worker_responses: 1,
+ evidence_items: 0,
+ },
+ tasks: [
+ {
+ task_id: 'T-1',
+ task_type: 'STAY_PERIOD_EXTENSION',
+ title: '응웬반A 체류연장 준비',
+ status: 'READY_FOR_REVIEW',
+ due_date: '2026-08-01',
+ },
+ ],
+ workflow_catalog_version: '1',
+ workflow_snapshot: {},
+ ...overrides,
+ }
+}
+
function mockTaskAndActivities(
taskOverrides: Partial = {},
activities: AuditEventResponse[] = [],
- readinessOverrides: Partial = {},
+ readinessOverrides: Partial | Response = {},
documents: DocumentItemResponse[] = [],
+ workerResponses: WorkerResponseItemResponse[] = [],
+ workerLinkDelivery: WorkerLinkDeliveryResponse | null = null,
+ caseProjection: CaseProjectionResponse | null = null,
) {
- vi.mocked(fetch).mockImplementation((input) => {
+ let responsesReviewed = false
+ let currentWorkerLinkDelivery = workerLinkDelivery
+ vi.mocked(fetch).mockImplementation((input, init) => {
const url = String(input)
if (url.includes('/activities')) return Promise.resolve(jsonResponse(activities))
- if (url.includes('/document-readiness'))
- return Promise.resolve(jsonResponse(readinessResponse(readinessOverrides)))
+ if (url.includes('/cases/') && url.endsWith('/projection')) {
+ return Promise.resolve(
+ caseProjection
+ ? jsonResponse(caseProjection)
+ : errorResponse(404, 'CASE_NOT_FOUND', 'Case 없음'),
+ )
+ }
+ if (url.endsWith('/worker-responses/read')) {
+ responsesReviewed = true
+ return Promise.resolve(new Response(null, { status: 204 }))
+ }
+ if (url.includes('/worker-responses?')) {
+ const items = workerResponses.map((response) =>
+ responsesReviewed
+ ? { ...response, unread: false, conversation_status: 'REOPENED' as const }
+ : response,
+ )
+ return Promise.resolve(
+ jsonResponse({
+ items,
+ page: 0,
+ size: 100,
+ total_elements: items.length,
+ total_pages: items.length ? 1 : 0,
+ }),
+ )
+ }
+ if (url.includes('/document-readiness')) {
+ return Promise.resolve(
+ readinessOverrides instanceof Response
+ ? readinessOverrides
+ : jsonResponse(readinessResponse(readinessOverrides)),
+ )
+ }
if (url.includes('/document-request-draft')) {
return Promise.resolve(
jsonResponse({ draft_id: 'draft-1', version: 1, review_status: 'PENDING' }),
@@ -155,10 +246,28 @@ function mockTaskAndActivities(
jsonResponse({ task_id: 'T-1', task_status: 'DRAFT', task_version: 2 }),
)
}
+ if (url.endsWith('/external-submissions')) {
+ return Promise.resolve(
+ jsonResponse(
+ {
+ resource_id: 'S-1',
+ task_id: 'T-1',
+ task_status: 'WAITING_EXTERNAL',
+ task_version: 2,
+ },
+ { status: 201 },
+ ),
+ )
+ }
if (url.endsWith('/evidence')) {
return Promise.resolve(
jsonResponse(
- { resource_id: 'E-1', task_id: 'T-1', task_status: 'APPROVED', task_version: 1 },
+ {
+ resource_id: 'E-1',
+ task_id: 'T-1',
+ task_status: 'WAITING_EXTERNAL',
+ task_version: 2,
+ },
{ status: 201 },
),
)
@@ -174,13 +283,44 @@ function mockTaskAndActivities(
)
}
if (url.endsWith('/worker-link')) {
+ if (init?.method !== 'POST') {
+ return Promise.resolve(
+ currentWorkerLinkDelivery
+ ? jsonResponse(currentWorkerLinkDelivery)
+ : errorResponse(404, 'WORKER_LINK_RESOURCE_NOT_FOUND', '근로자 링크 없음'),
+ )
+ }
+ currentWorkerLinkDelivery = {
+ worker_link_id: 'L-1',
+ link_status: 'ACTIVE',
+ delivery_status: 'NOT_SENT',
+ sent_at: null,
+ expires_at: '2026-08-07T00:00:00Z',
+ }
return Promise.resolve(
jsonResponse(
- { worker_url: 'worker-token-1', expires_at: '2026-08-07T00:00:00Z' },
+ {
+ worker_link_id: 'L-1',
+ worker_url: 'worker-token-1',
+ expires_at: '2026-08-07T00:00:00Z',
+ delivery_status: 'NOT_SENT',
+ sent_at: null,
+ already_issued: false,
+ },
{ status: 201 },
),
)
}
+ if (url.endsWith('/worker-links/L-1/sent')) {
+ currentWorkerLinkDelivery = {
+ worker_link_id: 'L-1',
+ link_status: 'ACTIVE',
+ delivery_status: 'SENT',
+ sent_at: '2026-08-05T01:00:00Z',
+ expires_at: '2026-08-07T00:00:00Z',
+ }
+ return Promise.resolve(jsonResponse(currentWorkerLinkDelivery))
+ }
return Promise.resolve(jsonResponse(task(taskOverrides)))
})
}
@@ -192,11 +332,20 @@ function mockTaskError(status: number, code: string, message: string) {
if (url.includes('/document-readiness'))
return Promise.resolve(jsonResponse(readinessResponse()))
if (url.includes('/documents?')) return Promise.resolve(jsonResponse(documentsResponse()))
+ if (url.includes('/worker-responses?')) {
+ return Promise.resolve(
+ jsonResponse({ items: [], page: 0, size: 100, total_elements: 0, total_pages: 0 }),
+ )
+ }
+ if (url.endsWith('/worker-link')) {
+ return Promise.resolve(errorResponse(404, 'WORKER_LINK_RESOURCE_NOT_FOUND', '근로자 링크 없음'))
+ }
return Promise.resolve(errorResponse(status, code, message))
})
}
beforeEach(() => {
+ useAuthStore.setState({ user: null, status: 'ready' })
useToastStore.setState({ toasts: [] })
vi.stubGlobal('fetch', vi.fn())
})
@@ -205,9 +354,9 @@ afterEach(() => {
vi.unstubAllGlobals()
})
-function renderPage() {
+function renderPage(initialEntry = '/tasks/T-1') {
render(
-
+
{
expect(await screen.findByText('서류 요청 초안을 저장했습니다.')).toBeInTheDocument()
})
- it('switches to the communication tab and shows message content', async () => {
+ it('shows real unread worker responses and marks them as reviewed', async () => {
+ const user = userEvent.setup()
+ mockTaskAndActivities({}, [], {}, [], [
+ {
+ response_id: 'response-1',
+ response_type: 'DOCUMENT_SUBMITTED',
+ message: '여권 사본을 제출했습니다.',
+ upload_ids: ['upload-1'],
+ conversation_status: 'NEEDS_FOLLOWUP',
+ unread: true,
+ received_at: '2026-07-20T01:00:00Z',
+ },
+ ])
+ renderPage()
+ await screen.findByText('응웬반A 체류연장 준비')
+
+ await user.click(screen.getByRole('tab', { name: CASE_TABS[3] }))
+
+ expect(screen.getByText('승인대기')).toBeInTheDocument()
+ expect(screen.getByText('여권 사본을 제출했습니다.')).toBeInTheDocument()
+ expect(screen.getByText('제출 파일 1개')).toBeInTheDocument()
+ expect(screen.queryByText('여권 사본 요청문 초안을 준비했습니다.')).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: '응답 확인 완료' }))
+
+ expect(await screen.findByText('근로자 응답을 확인 처리했습니다.')).toBeInTheDocument()
+ await waitFor(() => {
+ expect(screen.getByText('확인됨')).toBeInTheDocument()
+ })
+ expect(
+ vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/worker-responses/read')),
+ ).toBe(true)
+ })
+
+ it('shows a worker-response empty state instead of static demo messages', async () => {
const user = userEvent.setup()
mockTaskAndActivities()
renderPage()
@@ -351,7 +534,62 @@ describe('CaseDetailPage', () => {
await user.click(screen.getByRole('tab', { name: CASE_TABS[3] }))
- expect(screen.getByText(CASE_COMMUNICATION[0].message)).toBeInTheDocument()
+ expect(screen.getByText('서류대기')).toBeInTheDocument()
+ expect(screen.getByText('도착한 근로자 응답이 없습니다')).toBeInTheDocument()
+ })
+
+ it('uses the persisted link delivery status and records delivery after confirmation', async () => {
+ const user = userEvent.setup()
+ mockTaskAndActivities({}, [], {}, [], [], {
+ worker_link_id: 'L-1',
+ link_status: 'ACTIVE',
+ delivery_status: 'NOT_SENT',
+ sent_at: null,
+ expires_at: '2026-08-07T00:00:00Z',
+ })
+ renderPage()
+ await screen.findByText('응웬반A 체류연장 준비')
+
+ await user.click(screen.getByRole('tab', { name: CASE_TABS[3] }))
+
+ expect(screen.getByText('서류대기')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: '전달 완료 기록' }))
+ expect(screen.getByRole('dialog', { name: '링크 전달 완료 기록' })).toBeInTheDocument()
+
+ await user.click(screen.getByLabelText('근로자에게 링크를 직접 전달했습니다.'))
+ await user.click(screen.getByRole('button', { name: '전달 완료로 기록' }))
+
+ expect(await screen.findByText('근로자 링크 전달 완료를 기록했습니다.')).toBeInTheDocument()
+ await waitFor(() => expect(screen.getByText('요청전송')).toBeInTheDocument())
+ expect(
+ vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/worker-links/L-1/sent')),
+ ).toBe(true)
+ })
+
+ it('does not offer the response review action to a viewer', async () => {
+ const user = userEvent.setup()
+ useAuthStore.setState({
+ user: { name: 'viewer', workplace: 'FOWOCO', role: 'VIEWER' },
+ status: 'ready',
+ })
+ mockTaskAndActivities({}, [], {}, [], [
+ {
+ response_id: 'response-1',
+ response_type: 'QUESTION',
+ message: '어떤 서류가 필요한가요?',
+ upload_ids: [],
+ conversation_status: 'NEEDS_FOLLOWUP',
+ unread: true,
+ received_at: '2026-07-20T01:00:00Z',
+ },
+ ])
+ renderPage()
+ await screen.findByText('응웬반A 체류연장 준비')
+
+ await user.click(screen.getByRole('tab', { name: CASE_TABS[3] }))
+
+ expect(screen.getByText('미확인')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: '응답 확인 완료' })).not.toBeInTheDocument()
})
it('switches to the activity tab and shows real activity content', async () => {
@@ -373,6 +611,28 @@ describe('CaseDetailPage', () => {
expect(await screen.findByText(/완료 처리 불가 · 승인 · 필수 체크리스트/)).toBeInTheDocument()
})
+ it('blocks completion and offers retry when document readiness cannot be verified', async () => {
+ const user = userEvent.setup()
+ mockTaskAndActivities(
+ {},
+ [],
+ errorResponse(503, 'SERVICE_UNAVAILABLE', '서류 상태 확인 지연'),
+ )
+ renderPage()
+
+ expect(await screen.findByText('조회 실패')).toBeInTheDocument()
+ expect(screen.getByText('서류 상태 확인 지연')).toBeInTheDocument()
+ expect(screen.getByText(/완료 처리 불가.*서류 상태 확인/)).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: '다시 조회 →' }))
+
+ expect(
+ vi.mocked(fetch).mock.calls.filter(([url]) =>
+ String(url).includes('/document-readiness'),
+ ),
+ ).toHaveLength(2)
+ })
+
it('requests approval through the API and refetches the Task', async () => {
const user = userEvent.setup()
mockTaskAndActivities({
@@ -508,9 +768,17 @@ describe('CaseDetailPage', () => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
- it('opens the context drawer with every section and closes on Escape', async () => {
+ it('opens the context drawer with the real Case projection and closes on Escape', async () => {
const user = userEvent.setup()
- mockTaskAndActivities({}, [activity()])
+ mockTaskAndActivities(
+ { case_id: 'C-1' },
+ [activity()],
+ {},
+ [],
+ [],
+ null,
+ caseProjectionResponse(),
+ )
renderPage()
await screen.findByText('응웬반A 체류연장 준비')
@@ -518,22 +786,44 @@ describe('CaseDetailPage', () => {
await user.click(screen.getByRole('button', { name: '펼쳐 보기 →' }))
- expect(screen.getByRole('dialog')).toBeInTheDocument()
- expect(screen.getByText('Agent가 확인한 내용')).toBeInTheDocument()
- expect(screen.getByText(CONTEXT_DRAWER.agentConfirmed[0])).toBeInTheDocument()
- expect(screen.getByText('부족한 정보')).toBeInTheDocument()
- expect(screen.getByText(CONTEXT_DRAWER.missingInfo[0])).toBeInTheDocument()
- expect(screen.getByText('공식 출처')).toBeInTheDocument()
- expect(screen.getByText(CONTEXT_DRAWER.officialSources[0].label)).toBeInTheDocument()
- expect(screen.getByText('최근 활동')).toBeInTheDocument()
- expect(screen.getByText('Agent가 체류연장 요청문 초안을 작성함')).toBeInTheDocument()
- expect(screen.getByText('HR이 할 일')).toBeInTheDocument()
- expect(screen.getByText(CONTEXT_DRAWER.hrTodo[0])).toBeInTheDocument()
+ const drawer = screen.getByRole('dialog')
+ expect(drawer).toBeInTheDocument()
+ expect(within(drawer).getByText('Case 현황')).toBeInTheDocument()
+ expect(within(drawer).getByText('준비 현황')).toBeInTheDocument()
+ expect(within(drawer).getByText('검증 서류')).toBeInTheDocument()
+ expect(within(drawer).getByText('2 / 3')).toBeInTheDocument()
+ expect(within(drawer).getByText('연결된 업무')).toBeInTheDocument()
+ expect(within(drawer).getByText('체류기간 연장 · 검토 필요')).toBeInTheDocument()
+ expect(within(drawer).getByText('최근 활동')).toBeInTheDocument()
+ expect(within(drawer).getByText('Agent가 체류연장 요청문 초안을 작성함')).toBeInTheDocument()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
+ it('opens the real Case context immediately from a context deep link', async () => {
+ mockTaskAndActivities(
+ { case_id: 'C-1' },
+ [activity()],
+ {},
+ [],
+ [],
+ null,
+ caseProjectionResponse(),
+ )
+
+ renderPage('/tasks/T-1?context=open')
+
+ const drawer = await screen.findByRole('dialog', { name: '관련 Context' })
+ expect(await within(drawer).findByText('Case 현황')).toBeInTheDocument()
+ expect(within(drawer).getByText('응웬반A')).toBeInTheDocument()
+ expect(
+ vi.mocked(fetch).mock.calls.some(([url]) =>
+ String(url).endsWith('/cases/C-1/projection'),
+ ),
+ ).toBe(true)
+ })
+
it('records evidence and completes through the API when the server Task is approved', async () => {
const user = userEvent.setup()
mockTaskAndActivities({
@@ -557,6 +847,7 @@ describe('CaseDetailPage', () => {
await user.click(await screen.findByRole('button', { name: '완료 처리 시작 →' }))
expect(screen.getByRole('dialog', { name: '외부기관 업무 완료' })).toBeInTheDocument()
+ await user.type(screen.getByLabelText('제출 기관'), '수원출입국·외국인청')
await user.click(screen.getByRole('button', { name: '접수번호' }))
await user.type(screen.getByPlaceholderText('접수번호를 입력하세요'), 'HI-2026-0718-032')
await user.click(screen.getByLabelText('실제 제출은 담당자가 직접 수행했습니다.'))
@@ -567,6 +858,9 @@ describe('CaseDetailPage', () => {
)
expect(screen.getByText('업무를 완료했습니다.')).toBeInTheDocument()
+ expect(
+ vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/external-submissions')),
+ ).toBe(true)
expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/evidence'))).toBe(
true,
)
@@ -575,6 +869,47 @@ describe('CaseDetailPage', () => {
)
})
+ it('does not duplicate the external submission when retrying from WAITING_EXTERNAL', async () => {
+ const user = userEvent.setup()
+ mockTaskAndActivities({
+ status: 'WAITING_EXTERNAL',
+ checklist_items: [
+ {
+ checklist_item_id: 'chk-1',
+ item_code: 'passport',
+ label: '여권 사본 확인',
+ required: true,
+ completed: true,
+ completed_by: null,
+ completed_at: null,
+ version: 1,
+ },
+ ],
+ })
+ renderPage()
+ await screen.findByText('응웬반A 체류연장 준비')
+
+ await user.click(await screen.findByRole('button', { name: '완료 처리 시작 →' }))
+ expect(screen.queryByLabelText('제출 기관')).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: '접수번호' }))
+ await user.type(screen.getByPlaceholderText('접수번호를 입력하세요'), 'HI-2026-0718-032')
+ await user.click(screen.getByLabelText('실제 제출은 담당자가 직접 수행했습니다.'))
+ await user.click(
+ within(screen.getByRole('dialog', { name: '외부기관 업무 완료' })).getByRole('button', {
+ name: '완료 처리',
+ }),
+ )
+
+ expect(await screen.findByText('업무를 완료했습니다.')).toBeInTheDocument()
+ expect(
+ vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/external-submissions')),
+ ).toBe(false)
+ expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/evidence'))).toBe(
+ true,
+ )
+ })
+
it('issues the security link through the API and shows the real URL', async () => {
const user = userEvent.setup()
mockTaskAndActivities({ status: 'APPROVED' })
@@ -594,5 +929,6 @@ describe('CaseDetailPage', () => {
expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/worker-link'))).toBe(
true,
)
+ expect(screen.getByRole('button', { name: '전달 완료 기록' })).toBeEnabled()
})
})
diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx
index a469628..27019df 100644
--- a/src/pages/CaseDetailPage/CaseDetailPage.tsx
+++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx
@@ -1,15 +1,17 @@
import { useCallback, useEffect, useRef, useState } from 'react'
-import { useParams } from 'react-router-dom'
+import { useParams, useSearchParams } from 'react-router-dom'
import {
approveTask,
buildTaskApprovalSnapshot,
completeTask,
+ recordExternalSubmission,
recordTaskEvidence,
rejectTask,
requestTaskApproval,
type EvidenceType,
} from '../../api/approvals'
import { fetchTaskActivities } from '../../api/audit'
+import { fetchCaseProjection, type CaseProjectionResponse } from '../../api/cases'
import {
fetchDocumentReadiness,
fetchDocuments,
@@ -17,7 +19,16 @@ import {
} from '../../api/documents'
import { ApiError, getErrorMessage } from '../../api/errors'
import { cancelTask, fetchTaskById, updateChecklistItem } from '../../api/tasks'
-import { issueWorkerLink, resolveWorkerPortalUrl } from '../../api/workerLinks'
+import {
+ fetchTaskWorkerLinkDelivery,
+ fetchTaskWorkerResponses,
+ issueWorkerLink,
+ markTaskWorkerResponsesRead,
+ markWorkerLinkSent,
+ resolveWorkerPortalUrl,
+ type WorkerLinkDeliveryResponse,
+ type WorkerResponseType,
+} from '../../api/workerLinks'
import { AgentSourceLabel } from '../../components/ui/AgentSourceLabel/AgentSourceLabel'
import { AgentSummary } from '../../components/ui/AgentSummary/AgentSummary'
import { Button } from '../../components/ui/Button/Button'
@@ -27,24 +38,39 @@ import { EmptyState } from '../../components/ui/EmptyState/EmptyState'
import { StatusLabel, type StatusTone } from '../../components/ui/StatusLabel/StatusLabel'
import { Tabs } from '../../components/ui/Tabs/Tabs'
import { useApiQuery } from '../../hooks/useApiQuery'
+import { useAuthStore } from '../../store/authStore'
import { useToastStore } from '../../store/toastStore'
-import { ACTOR_TYPE_TO_AGENT_SOURCE, AUDIT_ACTION_LABEL } from '../../utils/auditLabels'
+import { ACTOR_TYPE_TO_AGENT_SOURCE, getAuditActionLabel } from '../../utils/auditLabels'
import { formatEventTime } from '../../utils/datetime'
import { TASK_SOURCE_LABEL, TASK_STATUS_LABEL, TASK_STATUS_TONE } from '../../utils/taskStatus'
import { getDocumentViewModel } from '../../view-models/documentViewModel'
import { getOperationalDateViewModel } from '../../view-models/dateViewModel'
+import {
+ getWorkerRequestStateViewModel,
+ type WorkerRequestState,
+} from '../../view-models/workerRequestStateViewModel'
import styles from './CaseDetailPage.module.css'
-import { CASE_COMMUNICATION, CASE_TABS, CONTEXT_DRAWER } from './caseDetailData'
+import { CASE_TABS } from './caseDetailData'
+import {
+ getCaseDisplayStatusPresentation,
+ getTaskStatusPresentation,
+ getWorkflowLabel,
+} from '../WorkListPage/workInboxPresentation'
import { ApprovalDecisionModal } from './overlays/ApprovalDecisionModal'
import { ApprovalRequestModal } from './overlays/ApprovalRequestModal'
-import { ExternalCompletionModal } from './overlays/ExternalCompletionModal'
+import {
+ ExternalCompletionModal,
+ type ExternalCompletionSubmission,
+} from './overlays/ExternalCompletionModal'
+import { LinkDeliveryConfirmModal } from './overlays/LinkDeliveryConfirmModal'
import { LinkReissueModal, type ReissueSubmission } from './overlays/LinkReissueModal'
import { LinkReissuedModal } from './overlays/LinkReissuedModal'
import { RejectionReasonModal } from './overlays/RejectionReasonModal'
type ApprovalOverlay = 'none' | 'request' | 'decision' | 'rejection'
type CompletionOverlay = 'none' | 'external'
-type LinkOverlay = 'none' | 'reissue' | 'reissued'
+type LinkOverlay = 'none' | 'reissue' | 'reissued' | 'delivery'
+type DeliveryConfirmReturn = 'none' | 'reissued'
const CASE_TAB_ITEMS = CASE_TABS.map((label) => ({ id: label, label }))
@@ -65,22 +91,74 @@ const EVIDENCE_TYPE_BY_LABEL: Record = {
'화면 캡처': 'OFFICIAL_RESULT',
}
+const WORKER_RESPONSE_TYPE_LABEL: Record = {
+ ACKNOWLEDGED: '확인했습니다',
+ QUESTION: '질문',
+ NOT_UNDERSTOOD: '이해가 어려워요',
+ DOCUMENT_SUBMITTED: '서류 제출',
+ DIFFICULT: '진행이 어려워요',
+}
+
+const WORKER_REQUEST_STATE_TONE: Record = {
+ DOCUMENT_WAITING: 'neutral',
+ REQUEST_SENT: 'info',
+ APPROVAL_WAITING: 'warning',
+ COMPLETED: 'success',
+}
+
+const CASE_LIFECYCLE_LABEL: Record = {
+ ACTIVE: '진행 중',
+ COMPLETED: '완료',
+ CANCELLED: '취소',
+}
+
+async function fetchTaskWorkerLinkDeliveryOrNull(
+ taskId: string,
+): Promise {
+ try {
+ return await fetchTaskWorkerLinkDelivery(taskId)
+ } catch (error) {
+ if (
+ error instanceof ApiError &&
+ (error.status === 404 || error.code === 'WORKER_LINK_RESOURCE_NOT_FOUND')
+ ) {
+ return null
+ }
+ throw error
+ }
+}
+
export function CaseDetailPage() {
const { taskId } = useParams()
+ const [searchParams, setSearchParams] = useSearchParams()
+ const contextRequested = searchParams.get('context') === 'open'
const [activeTab, setActiveTab] = useState(CASE_TABS[0])
const [moreMenuOpen, setMoreMenuOpen] = useState(false)
- const [contextDrawerOpen, setContextDrawerOpen] = useState(false)
+ const [contextDrawerOpen, setContextDrawerOpen] = useState(contextRequested)
const [approvalOverlay, setApprovalOverlay] = useState('none')
const [completionOverlay, setCompletionOverlay] = useState('none')
const [actionPending, setActionPending] = useState(false)
const [togglingItemId, setTogglingItemId] = useState(null)
const [linkOverlay, setLinkOverlay] = useState('none')
+ const [deliveryConfirmReturn, setDeliveryConfirmReturn] =
+ useState('none')
const [lastReissue, setLastReissue] = useState(null)
const [issuedWorkerUrl, setIssuedWorkerUrl] = useState(null)
const [issuedExpiresAt, setIssuedExpiresAt] = useState(null)
+ const [localWorkerLinkDelivery, setLocalWorkerLinkDelivery] = useState<{
+ taskId: string
+ data: WorkerLinkDeliveryResponse
+ } | null>(null)
+ const [markingLinkSent, setMarkingLinkSent] = useState(false)
+ const [markingResponsesRead, setMarkingResponsesRead] = useState(false)
const moreMenuRef = useRef(null)
+ const userRole = useAuthStore((state) => state.user?.role)
const showToast = useToastStore((state) => state.showToast)
+ useEffect(() => {
+ if (contextRequested) setContextDrawerOpen(true)
+ }, [contextRequested])
+
const taskFetcher = useCallback(() => fetchTaskById(taskId ?? ''), [taskId])
const {
status: taskStatus,
@@ -90,11 +168,16 @@ export function CaseDetailPage() {
} = useApiQuery(taskFetcher)
const activitiesFetcher = useCallback(() => fetchTaskActivities(taskId ?? ''), [taskId])
- const { data: activities } = useApiQuery(activitiesFetcher)
+ const { data: activities, refetch: refetchActivities } = useApiQuery(activitiesFetcher)
const activityRows = activities ?? []
const readinessFetcher = useCallback(() => fetchDocumentReadiness(taskId ?? ''), [taskId])
- const { data: readiness } = useApiQuery(readinessFetcher)
+ const {
+ status: readinessStatus,
+ data: readiness,
+ error: readinessError,
+ refetch: refetchReadiness,
+ } = useApiQuery(readinessFetcher)
const workerId = task?.worker_id
const documentsFetcher = useCallback(() => {
@@ -112,6 +195,62 @@ export function CaseDetailPage() {
)
const documents = documentsPage?.items ?? []
+ const workerResponsesFetcher = useCallback(
+ () => fetchTaskWorkerResponses(taskId ?? '', 0, 100),
+ [taskId],
+ )
+ const {
+ status: workerResponsesStatus,
+ data: workerResponsesPage,
+ error: workerResponsesError,
+ refetch: refetchWorkerResponses,
+ } = useApiQuery(
+ workerResponsesFetcher,
+ useCallback((page: { items: unknown[] }) => page.items.length === 0, []),
+ )
+ const workerResponses = workerResponsesPage?.items ?? []
+
+ const caseId = task?.case_id
+ const caseProjectionFetcher = useCallback(() => {
+ if (!caseId || !contextDrawerOpen) return Promise.resolve(null)
+ return fetchCaseProjection(caseId)
+ }, [caseId, contextDrawerOpen])
+ const {
+ status: caseProjectionStatus,
+ data: caseProjection,
+ error: caseProjectionError,
+ refetch: refetchCaseProjection,
+ } = useApiQuery(
+ caseProjectionFetcher,
+ useCallback((projection: CaseProjectionResponse | null) => projection === null, []),
+ )
+
+ const workerLinkDeliveryFetcher = useCallback(() => {
+ if (!taskId || userRole === 'VIEWER') return Promise.resolve(null)
+ return fetchTaskWorkerLinkDeliveryOrNull(taskId)
+ }, [taskId, userRole])
+ const {
+ status: workerLinkDeliveryStatus,
+ data: workerLinkDelivery,
+ error: workerLinkDeliveryError,
+ refetch: refetchWorkerLinkDelivery,
+ } = useApiQuery(
+ workerLinkDeliveryFetcher,
+ useCallback((delivery: WorkerLinkDeliveryResponse | null) => delivery === null, []),
+ )
+
+ useEffect(() => {
+ if (!localWorkerLinkDelivery || !workerLinkDelivery) return
+ if (
+ localWorkerLinkDelivery.taskId === taskId &&
+ workerLinkDelivery.worker_link_id === localWorkerLinkDelivery.data.worker_link_id &&
+ (workerLinkDelivery.delivery_status === localWorkerLinkDelivery.data.delivery_status ||
+ workerLinkDelivery.delivery_status === 'SENT')
+ ) {
+ setLocalWorkerLinkDelivery(null)
+ }
+ }, [localWorkerLinkDelivery, taskId, workerLinkDelivery])
+
useEffect(() => {
if (!moreMenuOpen) return
@@ -188,22 +327,50 @@ export function CaseDetailPage() {
setCompletionOverlay('external')
}
- async function handleCompleteExternal(evidenceType: string, evidenceValue: string, memo: string) {
+ async function handleCompleteExternal(submission: ExternalCompletionSubmission) {
if (!task || actionPending) return
- const normalizedEvidenceType = EVIDENCE_TYPE_BY_LABEL[evidenceType]
+ const normalizedEvidenceType = EVIDENCE_TYPE_BY_LABEL[submission.evidenceType]
if (!normalizedEvidenceType) return
+ const evidenceValue = submission.evidenceValue.trim()
+ const memo = submission.memo.trim()
+ let externalSubmissionRecorded = task.status === 'WAITING_EXTERNAL'
setActionPending(true)
try {
+ if (!externalSubmissionRecorded) {
+ await recordExternalSubmission(task.task_id, {
+ expected_version: task.version,
+ destination: submission.destination,
+ safe_reference: evidenceValue,
+ })
+ externalSubmissionRecorded = true
+ }
+
const evidence = await recordTaskEvidence(task.task_id, {
evidence_type: normalizedEvidenceType,
- note: [evidenceValue.trim(), memo.trim()].filter(Boolean).join(' · '),
+ file_reference:
+ normalizedEvidenceType === 'DOCUMENT' || normalizedEvidenceType === 'OFFICIAL_RESULT'
+ ? evidenceValue
+ : undefined,
+ note:
+ normalizedEvidenceType === 'RECEIPT'
+ ? [evidenceValue, memo].filter(Boolean).join(' · ')
+ : memo || undefined,
})
await completeTask(task.task_id, evidence.task_version)
setCompletionOverlay('none')
refetchTask()
+ refetchActivities()
showToast('업무를 완료했습니다.')
} catch (error) {
- showToast(error instanceof ApiError ? getErrorMessage(error) : '업무를 완료하지 못했습니다.')
+ refetchTask()
+ refetchActivities()
+ const detail =
+ error instanceof ApiError ? getErrorMessage(error) : '업무를 완료하지 못했습니다.'
+ showToast(
+ externalSubmissionRecorded
+ ? `외부 제출 기록은 저장됐습니다. ${detail}`
+ : detail,
+ )
} finally {
setActionPending(false)
}
@@ -288,10 +455,29 @@ export function CaseDetailPage() {
crypto.randomUUID(),
)
setLastReissue(submission)
- setIssuedWorkerUrl(resolveWorkerPortalUrl(issued.worker_url, window.location.origin))
+ setIssuedWorkerUrl(
+ issued.worker_url
+ ? resolveWorkerPortalUrl(issued.worker_url, window.location.origin)
+ : null,
+ )
setIssuedExpiresAt(issued.expires_at)
+ setLocalWorkerLinkDelivery({
+ taskId: task.task_id,
+ data: {
+ worker_link_id: issued.worker_link_id,
+ link_status: 'ACTIVE',
+ delivery_status: issued.delivery_status,
+ sent_at: issued.sent_at,
+ expires_at: issued.expires_at,
+ },
+ })
+ refetchWorkerLinkDelivery()
setLinkOverlay('reissued')
- showToast('보안 링크를 발급했습니다. 아직 자동 전송되지는 않았습니다.')
+ showToast(
+ issued.worker_url
+ ? '보안 링크를 발급했습니다. 아직 자동 전송되지는 않았습니다.'
+ : '링크는 이미 발급됐지만 보안상 원문을 다시 표시할 수 없습니다.',
+ )
} catch (error) {
showToast(
error instanceof ApiError ? getErrorMessage(error) : '보안 링크를 발급하지 못했습니다.',
@@ -301,6 +487,58 @@ export function CaseDetailPage() {
}
}
+ function handleOpenDeliveryConfirm(returnTo: DeliveryConfirmReturn = 'none') {
+ setDeliveryConfirmReturn(returnTo)
+ setLinkOverlay('delivery')
+ }
+
+ async function handleMarkLinkSent() {
+ if (!task || markingLinkSent) return
+ const currentDelivery =
+ localWorkerLinkDelivery?.taskId === task.task_id
+ ? localWorkerLinkDelivery.data
+ : workerLinkDelivery
+ if (!currentDelivery || currentDelivery.delivery_status === 'SENT') return
+
+ setMarkingLinkSent(true)
+ try {
+ const marked = await markWorkerLinkSent(currentDelivery.worker_link_id)
+ setLocalWorkerLinkDelivery({ taskId: task.task_id, data: marked })
+ refetchWorkerLinkDelivery()
+ refetchActivities()
+ setDeliveryConfirmReturn('none')
+ setLinkOverlay('none')
+ showToast('근로자 링크 전달 완료를 기록했습니다.')
+ } catch (error) {
+ showToast(
+ error instanceof ApiError
+ ? getErrorMessage(error)
+ : '링크 전달 완료를 기록하지 못했습니다.',
+ )
+ } finally {
+ setMarkingLinkSent(false)
+ }
+ }
+
+ async function handleMarkResponsesRead() {
+ if (!task || markingResponsesRead) return
+ setMarkingResponsesRead(true)
+ try {
+ await markTaskWorkerResponsesRead(task.task_id)
+ refetchWorkerResponses()
+ refetchActivities()
+ showToast('근로자 응답을 확인 처리했습니다.')
+ } catch (error) {
+ showToast(
+ error instanceof ApiError
+ ? getErrorMessage(error)
+ : '근로자 응답을 확인 처리하지 못했습니다.',
+ )
+ } finally {
+ setMarkingResponsesRead(false)
+ }
+ }
+
if (taskStatus === 'loading') {
return (
@@ -336,7 +574,9 @@ export function CaseDetailPage() {
const completedRequiredChecklist = requiredChecklist.filter((item) => item.completed).length
const checklistReady = completedRequiredChecklist === requiredChecklist.length
const informationReady = task.missing_required_slots.length === 0
- const documentsReady = readiness ? !readiness.completion_blocked : false
+ const documentsReady =
+ readinessStatus === 'success' && Boolean(readiness && !readiness.completion_blocked)
+ const documentsStateUnknown = readinessStatus === 'loading' || readinessStatus === 'error'
const approvalReady =
task.status === 'APPROVED' ||
task.status === 'WAITING_WORKER' ||
@@ -351,7 +591,7 @@ export function CaseDetailPage() {
!approvalReady && '승인',
!checklistReady && '필수 체크리스트',
!informationReady && '필수 정보',
- !documentsReady && '서류 준비',
+ !documentsReady && (documentsStateUnknown ? '서류 상태 확인' : '서류 준비'),
].filter(Boolean) as string[]
const firstIncompleteChecklistIndex = task.checklist_items.findIndex((item) => !item.completed)
const agentHeadline =
@@ -361,6 +601,27 @@ export function CaseDetailPage() {
const agentBody = completionBlockers.length
? `${completionBlockers.join(' · ')} 확인이 필요합니다.`
: (task.description ?? '필수 항목과 서류가 모두 준비되었습니다.')
+ const unreadWorkerResponseCount = workerResponses.filter((response) => response.unread).length
+ const newestWorkerResponse = workerResponses[0]
+ const currentWorkerLinkDelivery =
+ localWorkerLinkDelivery?.taskId === task.task_id
+ ? localWorkerLinkDelivery.data
+ : workerLinkDelivery
+ const workerRequestState = getWorkerRequestStateViewModel({
+ requestSentAt:
+ currentWorkerLinkDelivery?.delivery_status === 'SENT'
+ ? (currentWorkerLinkDelivery.sent_at ?? newestWorkerResponse?.received_at)
+ : newestWorkerResponse?.received_at,
+ responseReceivedAt: newestWorkerResponse?.received_at,
+ responseReadAt:
+ newestWorkerResponse && unreadWorkerResponseCount === 0
+ ? newestWorkerResponse.received_at
+ : null,
+ completedAt: task.status === 'COMPLETED' ? task.updated_at : null,
+ })
+ const caseDisplayStatus = caseProjection
+ ? getCaseDisplayStatusPresentation(caseProjection.display_status)
+ : null
function handleAgentAction() {
if (!task) return
@@ -375,6 +636,15 @@ export function CaseDetailPage() {
setActiveTab('체크리스트')
}
+ function handleCloseContext() {
+ setContextDrawerOpen(false)
+ if (!searchParams.has('context')) return
+
+ const nextParams = new URLSearchParams(searchParams)
+ nextParams.delete('context')
+ setSearchParams(nextParams, { replace: true })
+ }
+
return (
@@ -555,16 +825,38 @@ export function CaseDetailPage() {
+ {readinessStatus === 'error' && (
+
+
+ {readinessError
+ ? getErrorMessage(readinessError)
+ : '서류 상태를 불러오지 못했습니다.'}
+
+
+ 다시 조회 →
+
+
+ )}
- {/* TODO(backend): GET /api/work-items/:id/communication -> CASE_COMMUNICATION 대체 */}
-
- {CASE_COMMUNICATION.map((entry) => (
-
-
{entry.time}
-
{entry.actor}
-
{entry.message}
-
- ))}
-
+ {(workerResponsesStatus === 'loading' || workerLinkDeliveryStatus === 'loading') && (
+
+ )}
+ {(workerResponsesStatus === 'error' || workerLinkDeliveryStatus === 'error') && (
+ {
+ refetchWorkerResponses()
+ refetchWorkerLinkDelivery()
+ }}
+ />
+ )}
+ {(workerResponsesStatus === 'empty' || workerResponsesStatus === 'success') &&
+ (workerLinkDeliveryStatus === 'empty' || workerLinkDeliveryStatus === 'success') && (
+ <>
+
+
+
근로자 모바일 요청
+
+
+ 응답 현황
+
+
+ {workerRequestState.label}
+
+
+
{workerRequestState.description}
+
+ {unreadWorkerResponseCount > 0 && userRole !== 'VIEWER' && (
+
+ 응답 확인 완료
+
+ )}
+ {unreadWorkerResponseCount === 0 &&
+ currentWorkerLinkDelivery?.delivery_status === 'NOT_SENT' &&
+ userRole !== 'VIEWER' && (
+ handleOpenDeliveryConfirm('none')}
+ >
+ 전달 완료 기록
+
+ )}
+
+
+ {workerResponses.length === 0 ? (
+
+ ) : (
+
+ {workerResponses.map((response) => (
+
+
+
+ {formatEventTime(response.received_at)}
+
+
+ {response.unread ? '미확인' : '확인됨'}
+
+
+
+
+ 근로자
+
+ {WORKER_RESPONSE_TYPE_LABEL[response.response_type]}
+
+
+
+ {response.message?.trim() || '별도 메시지 없이 응답했습니다.'}
+
+ {response.upload_ids.length > 0 && (
+
제출 파일 {response.upload_ids.length}개
+ )}
+
+
+ ))}
+
+ )}
+ >
+ )}
)}
@@ -746,7 +1135,7 @@ export function CaseDetailPage() {
className={`${styles.timelineDot} ${index === 0 ? styles.timelineDotHighlighted : ''}`}
/>
- {entry.change_summary ?? AUDIT_ACTION_LABEL[entry.action]}
+ {entry.change_summary ?? getAuditActionLabel(entry.action)}
@@ -819,7 +1208,11 @@ export function CaseDetailPage() {
/>
setCompletionOverlay('none')}
+ submissionAlreadyRecorded={task.status === 'WAITING_EXTERNAL'}
+ submitting={actionPending}
+ onClose={() => {
+ if (!actionPending) setCompletionOverlay('none')
+ }}
onComplete={handleCompleteExternal}
/>
handleOpenDeliveryConfirm('reissued')}
onClose={() => setLinkOverlay('none')}
/>
+ setLinkOverlay(deliveryConfirmReturn)}
+ onConfirm={handleMarkLinkSent}
+ />
setContextDrawerOpen(false)}
+ onClose={handleCloseContext}
title="관련 Context"
>
- {/* TODO(backend): GET /api/work-items/:id/context -> CONTEXT_DRAWER 대체 */}
-
-
Agent가 확인한 내용
-
- {CONTEXT_DRAWER.agentConfirmed.map((item) => (
- {item}
- ))}
-
-
+ {caseId &&
+ (caseProjectionStatus === 'loading' || caseProjectionStatus === 'empty') && (
+
+ )}
-
-
부족한 정보
-
- {CONTEXT_DRAWER.missingInfo.map((item) => (
- {item}
- ))}
-
-
+ {caseProjectionStatus === 'error' && (
+
+ )}
-
-
공식 출처
- {CONTEXT_DRAWER.officialSources.map((source) => (
-
- ))}
-
+ {!caseId && caseProjectionStatus === 'empty' && (
+
+ )}
-
-
최근 활동
-
- {activityRows.slice(0, 3).map((entry, index) => (
-
- {formatEventTime(entry.created_at)}
-
-
- {entry.change_summary ?? AUDIT_ACTION_LABEL[entry.action]}
-
-
- ))}
-
-
+ {caseProjectionStatus === 'success' && caseProjection && caseDisplayStatus && (
+ <>
+
+
Case 현황
+
+
+ {caseDisplayStatus.label}
+ }
+ />
+
+
+
-
-
HR이 할 일
-
- {CONTEXT_DRAWER.hrTodo.map((item) => (
- {item}
- ))}
-
-
+
+
준비 현황
+
+
+
+
+
+
+
+
+
연결된 업무
+ {caseProjection.tasks.length === 0 ? (
+
연결된 업무가 없습니다.
+ ) : (
+ caseProjection.tasks.map((caseTask) => {
+ const status = getTaskStatusPresentation(caseTask.status)
+ return (
+
+ )
+ })
+ )}
+
+
+
+
최근 활동
+ {activityRows.length === 0 ? (
+
기록된 활동이 없습니다.
+ ) : (
+
+ {activityRows.slice(0, 3).map((entry, index) => (
+
+ {formatEventTime(entry.created_at)}
+
+
+ {entry.change_summary ?? getAuditActionLabel(entry.action)}
+
+
+ ))}
+
+ )}
+
+ >
+ )}
)
diff --git a/src/pages/CaseDetailPage/caseDetailData.ts b/src/pages/CaseDetailPage/caseDetailData.ts
index 55fad38..c28b878 100644
--- a/src/pages/CaseDetailPage/caseDetailData.ts
+++ b/src/pages/CaseDetailPage/caseDetailData.ts
@@ -1,45 +1 @@
-// TODO(backend): GET /api/work-items/:id -> 아래 상수 전체 대체
-// 제목·상태·체크리스트는 #153에서 실제 Task API로 대체됐다 (CaseDetailPage.tsx 참고).
-
export const CASE_TABS = ['현재 단계', '체크리스트', '문서', '소통', '활동이력']
-
-export const AGENT_SUMMARY = {
- headline: '여권 사본 요청문을 준비했습니다.',
- body: '승인이 완료되면 72시간 보안 링크가 활성화됩니다. 실제 전달은 HR이 직접 수행합니다.',
- actionLabel: '다음 행동 · 승인 요청',
-}
-
-export const CONTEXT_ACCESS = {
- label: '관련 Context',
- rows: [
- { label: '근거', value: 4 },
- { label: '문서', value: 3 },
- { label: '활동', value: 8 },
- ],
-}
-
-export const CONTEXT_DRAWER = {
- agentConfirmed: [
- '응웬반A의 체류만료일이 2026-08-03로 확인됨',
- '보유 서류 중 여권 사본, 표준근로계약서는 최신 상태',
- ],
- missingInfo: ['출입국 접수번호 미입력', '근로자 서명 확인 대기'],
- officialSources: [
- { label: '하이코리아 체류자격 안내', value: '2026-07-18 확인' },
- { label: '출입국·외국인정책본부 고시', value: '2026-07-10 확인' },
- ],
- hrTodo: ['근로자 안내문 최종 검토 후 승인 요청', '보안 링크 전달 전 담당자 직접 확인'],
-}
-
-export interface CaseCommunicationEntry {
- id: string
- time: string
- actor: string
- message: string
-}
-
-export const CASE_COMMUNICATION: CaseCommunicationEntry[] = [
- { id: 'comm-1', time: '오늘 09:12', actor: 'Agent', message: '여권 사본 요청문 초안을 준비했습니다.' },
- { id: 'comm-2', time: '어제 17:40', actor: '김경민', message: '근로자에게 서류 제출 안내 문자를 발송했습니다.' },
- { id: 'comm-3', time: '어제 09:05', actor: '응웬반A', message: '서류를 준비 중이라고 답장했습니다.' },
-]
diff --git a/src/pages/CaseDetailPage/overlays/ExternalCompletionModal.tsx b/src/pages/CaseDetailPage/overlays/ExternalCompletionModal.tsx
index a8f628b..35eee72 100644
--- a/src/pages/CaseDetailPage/overlays/ExternalCompletionModal.tsx
+++ b/src/pages/CaseDetailPage/overlays/ExternalCompletionModal.tsx
@@ -1,9 +1,16 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
import { Modal } from '../../../components/ui/Modal/Modal'
import styles from './overlays.module.css'
type EvidenceType = '접수번호' | '파일' | '화면 캡처'
+export interface ExternalCompletionSubmission {
+ destination: string
+ evidenceType: EvidenceType
+ evidenceValue: string
+ memo: string
+}
+
const EVIDENCE_TYPES: EvidenceType[] = ['접수번호', '파일', '화면 캡처']
const EVIDENCE_PLACEHOLDER: Record = {
@@ -14,25 +21,43 @@ const EVIDENCE_PLACEHOLDER: Record = {
export interface ExternalCompletionModalProps {
open: boolean
+ submissionAlreadyRecorded?: boolean
+ submitting?: boolean
onClose: () => void
- onComplete: (evidenceType: EvidenceType, evidenceValue: string, memo: string) => void
+ onComplete: (submission: ExternalCompletionSubmission) => void
}
-export function ExternalCompletionModal({ open, onClose, onComplete }: ExternalCompletionModalProps) {
+export function ExternalCompletionModal({
+ open,
+ submissionAlreadyRecorded = false,
+ submitting = false,
+ onClose,
+ onComplete,
+}: ExternalCompletionModalProps) {
+ const [destination, setDestination] = useState('')
const [evidenceType, setEvidenceType] = useState(null)
const [evidenceValue, setEvidenceValue] = useState('')
const [confirmed, setConfirmed] = useState(false)
const [memo, setMemo] = useState('')
- const isReady = evidenceType !== null && evidenceValue.trim() !== '' && confirmed
-
- function handleComplete() {
- if (!isReady || evidenceType === null) return
- onComplete(evidenceType, evidenceValue, memo)
+ useEffect(() => {
+ if (open) return
+ setDestination('')
setEvidenceType(null)
setEvidenceValue('')
setConfirmed(false)
setMemo('')
+ }, [open])
+
+ const isReady =
+ (submissionAlreadyRecorded || destination.trim() !== '') &&
+ evidenceType !== null &&
+ evidenceValue.trim() !== '' &&
+ confirmed
+
+ function handleComplete() {
+ if (!isReady || evidenceType === null) return
+ onComplete({ destination: destination.trim(), evidenceType, evidenceValue, memo })
}
return (
@@ -47,6 +72,27 @@ export function ExternalCompletionModal({ open, onClose, onComplete }: ExternalC
완료할 수 없습니다 · 증빙을 등록해 주세요.
)}
+ {!submissionAlreadyRecorded && (
+
+
+ 제출 기관
+
+ setDestination(event.target.value)}
+ placeholder="예: 수원출입국·외국인청"
+ maxLength={160}
+ disabled={submitting}
+ />
+
+ )}
+
+ {submissionAlreadyRecorded && (
+ 외부기관 제출 기록이 확인되었습니다.
+ )}
+
허용된 증빙 유형
{EVIDENCE_TYPES.map((type) => (
@@ -55,6 +101,7 @@ export function ExternalCompletionModal({ open, onClose, onComplete }: ExternalC
type="button"
className={`${styles.chip} ${evidenceType === type ? styles.chipSelected : ''}`}
onClick={() => setEvidenceType(type)}
+ disabled={submitting}
>
{type}
@@ -68,6 +115,8 @@ export function ExternalCompletionModal({ open, onClose, onComplete }: ExternalC
value={evidenceValue}
onChange={(event) => setEvidenceValue(event.target.value)}
placeholder={EVIDENCE_PLACEHOLDER[evidenceType]}
+ maxLength={300}
+ disabled={submitting}
/>
)}
@@ -77,12 +126,17 @@ export function ExternalCompletionModal({ open, onClose, onComplete }: ExternalC
type="checkbox"
checked={confirmed}
onChange={(event) => setConfirmed(event.target.checked)}
+ disabled={submitting}
/>
실제 제출은 담당자가 직접 수행했습니다.
{!isReady && (
- 증빙과 담당자 확인을 완료해야 버튼이 활성화됩니다.
+
+ {submissionAlreadyRecorded
+ ? '증빙과 담당자 확인을 완료해야 버튼이 활성화됩니다.'
+ : '제출 기관, 증빙과 담당자 확인을 완료해야 버튼이 활성화됩니다.'}
+
)}
{isReady && (
@@ -91,15 +145,22 @@ export function ExternalCompletionModal({ open, onClose, onComplete }: ExternalC
value={memo}
onChange={(event) => setMemo(event.target.value)}
placeholder="완료 메모 · 선택사항"
+ maxLength={500}
+ disabled={submitting}
/>
)}
-
+
취소
-
- 완료 처리
+
+ {submitting ? '완료 처리 중…' : '완료 처리'}
diff --git a/src/pages/CaseDetailPage/overlays/LinkDeliveryConfirmModal.tsx b/src/pages/CaseDetailPage/overlays/LinkDeliveryConfirmModal.tsx
new file mode 100644
index 0000000..1377217
--- /dev/null
+++ b/src/pages/CaseDetailPage/overlays/LinkDeliveryConfirmModal.tsx
@@ -0,0 +1,65 @@
+import { useEffect, useState } from 'react'
+import { Modal } from '../../../components/ui/Modal/Modal'
+import styles from './overlays.module.css'
+
+export interface LinkDeliveryConfirmModalProps {
+ open: boolean
+ submitting?: boolean
+ onClose: () => void
+ onConfirm: () => void
+}
+
+export function LinkDeliveryConfirmModal({
+ open,
+ submitting = false,
+ onClose,
+ onConfirm,
+}: LinkDeliveryConfirmModalProps) {
+ const [confirmed, setConfirmed] = useState(false)
+
+ useEffect(() => {
+ if (!open) setConfirmed(false)
+ }, [open])
+
+ function handleConfirm() {
+ if (!confirmed || submitting) return
+ onConfirm()
+ }
+
+ return (
+
+
+ SMS나 메신저로 링크를 직접 전달한 경우에만 완료로 기록해 주세요.
+
+
+
+
+ 이 기록은 근로자의 실제 수신을 보장하지 않으며, 업무 상태와 감사 이력에 반영됩니다.
+
+
+
+
+ setConfirmed(event.target.checked)}
+ />
+ 근로자에게 링크를 직접 전달했습니다.
+
+
+
+
+ 취소
+
+
+ {submitting ? '기록 중…' : '전달 완료로 기록'}
+
+
+
+ )
+}
diff --git a/src/pages/CaseDetailPage/overlays/LinkReissuedModal.tsx b/src/pages/CaseDetailPage/overlays/LinkReissuedModal.tsx
index 044be9c..57b6d17 100644
--- a/src/pages/CaseDetailPage/overlays/LinkReissuedModal.tsx
+++ b/src/pages/CaseDetailPage/overlays/LinkReissuedModal.tsx
@@ -9,6 +9,8 @@ export interface LinkReissuedModalProps {
submission: ReissueSubmission | null
workerUrl: string | null
expiresAt: string | null
+ canRecordDelivery: boolean
+ onRecordDelivery: () => void
onClose: () => void
}
@@ -17,6 +19,8 @@ export function LinkReissuedModal({
submission,
workerUrl,
expiresAt,
+ canRecordDelivery,
+ onRecordDelivery,
onClose,
}: LinkReissuedModalProps) {
const showToast = useToastStore((state) => state.showToast)
@@ -60,8 +64,13 @@ export function LinkReissuedModal({
닫기
-
- 새 링크 복사
+
+ 전달 완료 기록
diff --git a/src/pages/CaseDetailPage/overlays/overlays.module.css b/src/pages/CaseDetailPage/overlays/overlays.module.css
index e24ff7e..659dd32 100644
--- a/src/pages/CaseDetailPage/overlays/overlays.module.css
+++ b/src/pages/CaseDetailPage/overlays/overlays.module.css
@@ -89,6 +89,12 @@
cursor: pointer;
}
+.textLink:disabled,
+.chip:disabled {
+ opacity: 0.42;
+ cursor: not-allowed;
+}
+
.primaryButton {
height: 48px;
padding: 0 24px;
diff --git a/src/pages/CaseDetailPage/overlays/overlays.test.tsx b/src/pages/CaseDetailPage/overlays/overlays.test.tsx
index fbbb3b4..28e75d4 100644
--- a/src/pages/CaseDetailPage/overlays/overlays.test.tsx
+++ b/src/pages/CaseDetailPage/overlays/overlays.test.tsx
@@ -5,6 +5,7 @@ import { ApprovalRequestModal } from './ApprovalRequestModal'
import { ApprovalDecisionModal } from './ApprovalDecisionModal'
import { RejectionReasonModal } from './RejectionReasonModal'
import { ExternalCompletionModal } from './ExternalCompletionModal'
+import { LinkDeliveryConfirmModal } from './LinkDeliveryConfirmModal'
describe('ApprovalRequestModal', () => {
it('calls onSubmit when the request button is clicked', async () => {
@@ -90,6 +91,7 @@ describe('ExternalCompletionModal', () => {
expect(completeButton).toBeDisabled()
expect(screen.getByText('완료할 수 없습니다 · 증빙을 등록해 주세요.')).toBeInTheDocument()
+ await user.type(screen.getByLabelText('제출 기관'), '수원출입국·외국인청')
await user.click(screen.getByRole('button', { name: '접수번호' }))
await user.type(screen.getByPlaceholderText('접수번호를 입력하세요'), 'HI-2026-0718-032')
expect(completeButton).toBeDisabled()
@@ -99,6 +101,59 @@ describe('ExternalCompletionModal', () => {
expect(screen.getByText('✓ 완료 조건을 모두 충족했습니다.')).toBeInTheDocument()
await user.click(completeButton)
- expect(onComplete).toHaveBeenCalledWith('접수번호', 'HI-2026-0718-032', '')
+ expect(onComplete).toHaveBeenCalledWith({
+ destination: '수원출입국·외국인청',
+ evidenceType: '접수번호',
+ evidenceValue: 'HI-2026-0718-032',
+ memo: '',
+ })
+ })
+
+ it('does not request the destination again after an external submission was recorded', async () => {
+ const user = userEvent.setup()
+ const onComplete = vi.fn()
+ render(
+ ,
+ )
+
+ expect(screen.queryByLabelText('제출 기관')).not.toBeInTheDocument()
+ expect(screen.getByText('외부기관 제출 기록이 확인되었습니다.')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: '접수번호' }))
+ await user.type(screen.getByPlaceholderText('접수번호를 입력하세요'), 'HI-2026-0718-032')
+ await user.click(screen.getByLabelText('실제 제출은 담당자가 직접 수행했습니다.'))
+ await user.click(screen.getByRole('button', { name: '완료 처리' }))
+
+ expect(onComplete).toHaveBeenCalledWith(
+ expect.objectContaining({ destination: '', evidenceValue: 'HI-2026-0718-032' }),
+ )
+ })
+})
+
+describe('LinkDeliveryConfirmModal', () => {
+ it('requires an explicit delivery confirmation before recording', async () => {
+ const user = userEvent.setup()
+ const onConfirm = vi.fn()
+ render(
+ ,
+ )
+
+ const confirmButton = screen.getByRole('button', { name: '전달 완료로 기록' })
+ expect(confirmButton).toBeDisabled()
+
+ await user.click(screen.getByLabelText('근로자에게 링크를 직접 전달했습니다.'))
+ expect(confirmButton).toBeEnabled()
+
+ await user.click(confirmButton)
+ expect(onConfirm).toHaveBeenCalledOnce()
})
})
diff --git a/src/pages/DocumentListPage/DocumentListPage.module.css b/src/pages/DocumentListPage/DocumentListPage.module.css
index 9d6eb86..0529615 100644
--- a/src/pages/DocumentListPage/DocumentListPage.module.css
+++ b/src/pages/DocumentListPage/DocumentListPage.module.css
@@ -11,6 +11,38 @@
color: var(--text-secondary);
}
+.workerFilterNotice {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--fowoco-spacing-12);
+ margin-top: var(--fowoco-spacing-16);
+ padding: 10px 12px;
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-8);
+ background: var(--surface-subtle);
+ font-size: 13px;
+ line-height: 20px;
+ color: var(--text-secondary);
+}
+
+.workerFilterNotice button {
+ flex: 0 0 auto;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--brand-primary);
+ cursor: pointer;
+}
+
+.workerFilterNotice button:focus-visible {
+ outline: 2px solid var(--brand-primary);
+ outline-offset: 2px;
+}
+
.metricStrip {
display: grid;
grid-template-columns: repeat(4, 1fr);
diff --git a/src/pages/DocumentListPage/DocumentListPage.test.tsx b/src/pages/DocumentListPage/DocumentListPage.test.tsx
index d2b2c38..b32f375 100644
--- a/src/pages/DocumentListPage/DocumentListPage.test.tsx
+++ b/src/pages/DocumentListPage/DocumentListPage.test.tsx
@@ -70,9 +70,9 @@ function pageResponse(items: DocumentItemResponse[]): DocumentPageResponse {
return { items, page: 0, size: 100, total_elements: items.length }
}
-function renderPage() {
+function renderPage(initialEntry = '/documents') {
render(
-
+
} />
서류 상세
} />
@@ -130,6 +130,25 @@ describe('DocumentListPage', () => {
expect(screen.getByText('쩐티B')).toBeInTheDocument()
})
+ it('requests and explains a worker-scoped document view from the work inbox', async () => {
+ const user = userEvent.setup()
+ vi.mocked(fetch)
+ .mockResolvedValueOnce(jsonResponse(pageResponse([DOCUMENTS[0]])))
+ .mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS)))
+ renderPage('/documents?workerId=W-1')
+
+ expect(
+ await screen.findByText('업무함에서 선택한 근로자의 문서만 표시합니다.'),
+ ).toBeInTheDocument()
+ expect(String(vi.mocked(fetch).mock.calls[0][0])).toContain('workerId=W-1')
+
+ await user.click(screen.getByRole('button', { name: '전체 문서 보기' }))
+
+ await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2))
+ expect(String(vi.mocked(fetch).mock.calls[1][0])).not.toContain('workerId=')
+ expect(screen.queryByText('업무함에서 선택한 근로자의 문서만 표시합니다.')).not.toBeInTheDocument()
+ })
+
it('filters documents by tab', async () => {
const user = userEvent.setup()
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS)))
diff --git a/src/pages/DocumentListPage/DocumentListPage.tsx b/src/pages/DocumentListPage/DocumentListPage.tsx
index 8354b9e..0892dc4 100644
--- a/src/pages/DocumentListPage/DocumentListPage.tsx
+++ b/src/pages/DocumentListPage/DocumentListPage.tsx
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react'
-import { useNavigate } from 'react-router-dom'
+import { useNavigate, useSearchParams } from 'react-router-dom'
import { fetchDocuments, type DocumentItemResponse } from '../../api/documents'
import { getErrorMessage } from '../../api/errors'
import { EmptyState } from '../../components/ui/EmptyState/EmptyState'
@@ -42,13 +42,15 @@ const DOCUMENT_TABS: { id: TabId; label: string }[] = [
export function DocumentListPage() {
const navigate = useNavigate()
+ const [searchParams, setSearchParams] = useSearchParams()
+ const workerId = searchParams.get('workerId')?.trim() || null
const [activeTab, setActiveTab] = useState('all')
const [query, setQuery] = useState('')
const [uploadModalOpen, setUploadModalOpen] = useState(false)
const debouncedQuery = useDebouncedValue(query)
const { status, data, error, refetch } = useApiQuery(
- useCallback(() => fetchDocuments({ size: 100 }), []),
+ useCallback(() => fetchDocuments({ workerId: workerId ?? undefined, size: 100 }), [workerId]),
useCallback((page: { items: unknown[] }) => page.items.length === 0, []),
)
const documents = useMemo(() => data?.items ?? [], [data])
@@ -92,6 +94,12 @@ export function DocumentListPage() {
navigate(`/documents/${workerDocumentId}`)
}
+ function handleClearWorkerFilter() {
+ const nextParams = new URLSearchParams(searchParams)
+ nextParams.delete('workerId')
+ setSearchParams(nextParams)
+ }
+
return (
근로자별 서류 제출 현황
@@ -99,6 +107,15 @@ export function DocumentListPage() {
서류 없음·승인 대기·완료 상태와 문서 만료일을 서버 응답 기준으로 확인합니다.
+ {workerId && (
+
+ 업무함에서 선택한 근로자의 문서만 표시합니다.
+
+ 전체 문서 보기
+
+
+ )}
+
{metricStrip.map((metric) => (
diff --git a/src/pages/WorkListPage/WorkInboxDetail.tsx b/src/pages/WorkListPage/WorkInboxDetail.tsx
index ef508fb..fad60c5 100644
--- a/src/pages/WorkListPage/WorkInboxDetail.tsx
+++ b/src/pages/WorkListPage/WorkInboxDetail.tsx
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'
import { Button } from '../../components/ui/Button/Button'
import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel'
-import { useToastStore } from '../../store/toastStore'
import type { WorkInboxWorkerGroup } from './workInboxModel'
import {
getCaseDisplayStatusPresentation,
@@ -12,11 +11,17 @@ import {
getWorkflowLabel,
isReviewCase,
} from './workInboxPresentation'
+import { isActiveWorkInboxCase } from './workInboxModel'
import styles from './WorkListPage.module.css'
interface WorkInboxDetailProps {
group: WorkInboxWorkerGroup
onOpenTask: (taskId: string) => void
+ onOpenTaskContext: (taskId: string) => void
+ onCreateWork: (workerId: string, workerDisplayName: string) => void
+ onOpenWorker: (workerId: string) => void
+ onOpenDocuments: (workerId: string) => void
+ casesUnavailable?: boolean
}
const NATIONALITY_LABEL: Record
= {
@@ -29,42 +34,111 @@ const NATIONALITY_LABEL: Record = {
TH: '태국',
}
+const WORK_STATUS_LABEL = {
+ ACTIVE: '재직',
+ ON_LEAVE: '휴직',
+ RESIGNED: '퇴사',
+ TERMINATED: '계약 종료',
+} as const
+
function getWorkerMeta(group: WorkInboxWorkerGroup): string {
const worker = group.worker
if (!worker) return '근무 정보 확인 필요'
const nationality = NATIONALITY_LABEL[worker.nationality_code] ?? worker.nationality_code
- const workStatus = worker.work_status === 'ACTIVE' ? '재직' : '근무 상태 확인 필요'
- return `${nationality} · ${workStatus} · 비자·근무 정보 미등록`
+ const workStatus = WORK_STATUS_LABEL[worker.work_status]
+ const visa = worker.visa_type ? `${worker.visa_type} 비자` : '비자 미등록'
+ return `${nationality} · ${workStatus} · ${visa}`
}
-export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {
- const showToast = useToastStore((state) => state.showToast)
- const [activeCaseId, setActiveCaseId] = useState(group.primaryCase.case_id)
+export function WorkInboxDetail({
+ group,
+ onOpenTask,
+ onOpenTaskContext,
+ onCreateWork,
+ onOpenWorker,
+ onOpenDocuments,
+ casesUnavailable = false,
+}: WorkInboxDetailProps) {
+ const [activeCaseId, setActiveCaseId] = useState(group.primaryCase?.case_id ?? null)
// 근로자를 바꾸면 새 근로자의 우선 Case로 되돌린다.
useEffect(() => {
- setActiveCaseId(group.primaryCase.case_id)
- }, [group.workerId, group.primaryCase.case_id])
+ setActiveCaseId(group.primaryCase?.case_id ?? null)
+ }, [group.workerId, group.primaryCase?.case_id])
+
+ const detailTitleId = `work-inbox-detail-${group.workerId}`
+ const activeCases = group.cases.filter(isActiveWorkInboxCase)
+
+ if (activeCases.length === 0 || !group.primaryCase) {
+ const emptyTitle = casesUnavailable
+ ? '업무 정보를 확인하지 못했습니다'
+ : '현재 진행 중인 업무가 없습니다'
+ const emptyBody = casesUnavailable
+ ? '근로자 정보는 불러왔지만 업무 목록 연결에 실패했습니다. 잠시 후 다시 확인해 주세요.'
+ : group.historyCaseCount > 0
+ ? `완료·취소된 업무 이력 ${group.historyCaseCount}건이 있습니다. 필요한 후속 업무를 새로 요청할 수 있습니다.`
+ : '이 근로자에게 필요한 업무를 요청하면 Agent가 절차와 준비 서류를 정리합니다.'
+
+ return (
+
+
+
+
+ {group.workerDisplayName}
+
+
{getWorkerMeta(group)}
+
+ onOpenWorker(group.workerId)}
+ >
+ ···
+
+
+
+
+
+
+ {casesUnavailable ? '업무 연결 확인 필요' : '업무 없음'}
+
+
{emptyTitle}
+
{emptyBody}
+
+
+ onCreateWork(group.workerId, group.workerDisplayName)}
+ >
+ 새 업무 요청
+
+ onOpenWorker(group.workerId)}>
+ 근로자 정보 보기
+
+ onOpenDocuments(group.workerId)}>
+ 문서 확인
+
+
+
+
+ )
+ }
- const activeCaseIndex = group.cases.findIndex((item) => item.case_id === activeCaseId)
- const activeCase = group.cases[activeCaseIndex] ?? group.primaryCase
+ const activeCaseIndex = activeCases.findIndex((item) => item.case_id === activeCaseId)
+ const activeCase = activeCases[activeCaseIndex] ?? group.primaryCase
const activeTask = activeCase.current_task
const due = getDuePresentation(activeTask?.due_date ?? activeCase.due_date)
const activeStatus = getCaseDisplayStatusPresentation(activeCase.display_status)
- const reviewCases = group.cases.filter((item) => isReviewCase(item.display_status))
- const detailTitleId = `work-inbox-detail-${group.workerId}`
+ const reviewCases = activeCases.filter((item) => isReviewCase(item.display_status))
function handleOpenOtherCase() {
- const nextIndex = (Math.max(activeCaseIndex, 0) + 1) % group.cases.length
- setActiveCaseId(group.cases[nextIndex].case_id)
+ const nextIndex = (Math.max(activeCaseIndex, 0) + 1) % activeCases.length
+ setActiveCaseId(activeCases[nextIndex].case_id)
}
function handleViewEvidence() {
- showToast('판단 근거 보기는 준비 중입니다.')
- }
-
- function handleOpenWorkerMenu() {
- showToast('근로자 업무 메뉴는 준비 중입니다.')
+ if (activeTask) onOpenTaskContext(activeTask.task_id)
}
return (
@@ -80,8 +154,8 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {
onOpenWorker(group.workerId)}
>
···
@@ -96,7 +170,7 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {
- 우선 업무 건 · {Math.max(activeCaseIndex, 0) + 1}/{group.cases.length}
+ 우선 업무 건 · {Math.max(activeCaseIndex, 0) + 1}/{activeCases.length}
{activeTask && (
- {group.cases.length > 1 && (
+ {activeCases.length > 1 && (
다른 Case 열기 →
@@ -183,7 +257,12 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {
현재 결정
-
+
근거 보기
›
@@ -195,7 +274,7 @@ export function WorkInboxDetail({ group, onOpenTask }: WorkInboxDetailProps) {
{activeTask ? getDecisionSummary(activeTask.status) : activeStatus.label}
- 진행 업무 건 {group.cases.length}개 · 확인할 업무 {reviewCases.length}개 · 자동
+ 진행 업무 건 {group.activeCaseCount}개 · 확인할 업무 {reviewCases.length}개 · 자동
확정되지 않음
diff --git a/src/pages/WorkListPage/WorkInboxTargetList.tsx b/src/pages/WorkListPage/WorkInboxTargetList.tsx
index 29cef92..f76e0c8 100644
--- a/src/pages/WorkListPage/WorkInboxTargetList.tsx
+++ b/src/pages/WorkListPage/WorkInboxTargetList.tsx
@@ -55,10 +55,16 @@ export function WorkInboxTargetList({
{groups.map((group, index) => {
- const currentTask = group.primaryCase.current_task
- const due = getDuePresentation(currentTask?.due_date ?? group.primaryCase.due_date)
- const reviewStage = getReviewStageLink(currentTask?.status ?? 'DRAFT')
+ const primaryCase = group.primaryCase
+ const currentTask = primaryCase?.current_task ?? null
+ const due = primaryCase
+ ? getDuePresentation(currentTask?.due_date ?? primaryCase.due_date)
+ : null
+ const reviewStage = primaryCase
+ ? getReviewStageLink(currentTask?.status ?? 'DRAFT')
+ : null
const selected = group.workerId === selectedWorkerId
+ const hasActiveWork = group.activeCaseCount > 0
return (
{group.workerDisplayName}
- event.stopPropagation()}
- >
- {reviewStage.label}
-
+ {hasActiveWork && reviewStage ? (
+ event.stopPropagation()}
+ >
+ {reviewStage.label}
+
+ ) : (
+
+ 업무 없음
+
+ )}
- {currentTask ? getWorkflowLabel(currentTask) : group.primaryCase.title} · {due.label}
+ {hasActiveWork && primaryCase && due
+ ? `${currentTask ? getWorkflowLabel(currentTask) : primaryCase.title} · ${due.label}`
+ : group.historyCaseCount > 0
+ ? `완료·취소 이력 ${group.historyCaseCount}건`
+ : '새 업무를 요청할 수 있습니다'}
)
diff --git a/src/pages/WorkListPage/WorkListPage.module.css b/src/pages/WorkListPage/WorkListPage.module.css
index eac9d90..83fbd05 100644
--- a/src/pages/WorkListPage/WorkListPage.module.css
+++ b/src/pages/WorkListPage/WorkListPage.module.css
@@ -41,6 +41,54 @@
height: 40px;
}
+.toolbarControls,
+.statusFilters {
+ display: flex;
+ align-items: center;
+}
+
+.toolbarControls {
+ flex: 0 0 auto;
+ gap: var(--fowoco-spacing-12);
+}
+
+.statusFilters {
+ gap: var(--fowoco-spacing-4);
+ padding: 3px;
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-8);
+ background: var(--surface-page);
+}
+
+.statusFilter {
+ min-height: 32px;
+ padding: 5px 11px;
+ border: 0;
+ border-radius: var(--fowoco-radius-6);
+ background: transparent;
+ font-family: inherit;
+ font-size: 12px;
+ line-height: 20px;
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+
+.statusFilter:hover {
+ color: var(--text-primary);
+}
+
+.statusFilter:focus-visible {
+ outline: 2px solid var(--brand-primary);
+ outline-offset: 1px;
+}
+
+.statusFilterActive {
+ background: var(--surface-default);
+ box-shadow: var(--shadow-sm);
+ color: var(--brand-primary);
+ font-weight: 600;
+}
+
.stateWrap {
margin-top: var(--fowoco-spacing-24);
}
@@ -69,11 +117,27 @@
}
.noticeWarning {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--fowoco-spacing-12);
border-color: var(--fowoco-amber-600);
background: var(--fowoco-amber-50);
color: var(--text-primary);
}
+.noticeWarning button {
+ flex: 0 0 auto;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ font-family: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--brand-primary);
+ cursor: pointer;
+}
+
.workspace {
display: grid;
grid-template-columns: clamp(280px, 28.82%, 332px) minmax(0, 1fr);
@@ -297,6 +361,57 @@
display: none;
}
+.noWorkPanel {
+ min-height: 0;
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ padding: clamp(32px, 7vw, 80px);
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-8);
+ background: var(--surface-subtle);
+}
+
+.noWorkContent {
+ max-width: 560px;
+}
+
+.noWorkEyebrow {
+ display: inline-flex;
+ align-items: center;
+ min-height: 26px;
+ padding: 3px 9px;
+ border-radius: var(--fowoco-radius-999);
+ background: var(--surface-default);
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 20px;
+ color: var(--text-secondary);
+}
+
+.noWorkTitle {
+ margin: var(--fowoco-spacing-16) 0 0;
+ font-size: 22px;
+ line-height: 30px;
+ color: var(--text-primary);
+}
+
+.noWorkBody {
+ margin: var(--fowoco-spacing-8) 0 0;
+ font-size: 14px;
+ line-height: 22px;
+ color: var(--text-secondary);
+}
+
+.noWorkActions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--fowoco-spacing-8);
+ margin-top: var(--fowoco-spacing-24);
+}
+
.moreButton:hover {
border-color: var(--brand-primary);
color: var(--brand-primary);
@@ -414,6 +529,13 @@
text-decoration: underline;
}
+.textLink:disabled {
+ color: var(--text-secondary);
+ cursor: not-allowed;
+ opacity: 0.6;
+ text-decoration: none;
+}
+
.textLink:focus-visible {
outline: 2px solid var(--brand-primary);
outline-offset: 2px;
@@ -640,11 +762,27 @@
flex-direction: column;
}
+ .toolbarControls {
+ flex-wrap: wrap;
+ }
+
+ .statusFilters {
+ flex: 1 1 auto;
+ }
+
+ .statusFilter {
+ flex: 1;
+ }
+
.toolbar > * {
width: 100% !important;
max-width: none;
}
+ .toolbar > .searchInput {
+ flex: 0 0 40px;
+ }
+
.workspace {
min-height: 0;
}
@@ -653,6 +791,15 @@
padding: var(--fowoco-spacing-16);
}
+ .noWorkPanel {
+ padding: var(--fowoco-spacing-24);
+ }
+
+ .noWorkActions,
+ .noWorkActions > * {
+ width: 100%;
+ }
+
.reviewTaskRow {
grid-template-columns: minmax(0, 1fr) auto;
}
diff --git a/src/pages/WorkListPage/WorkListPage.test.tsx b/src/pages/WorkListPage/WorkListPage.test.tsx
index 75ec0b3..7a0e455 100644
--- a/src/pages/WorkListPage/WorkListPage.test.tsx
+++ b/src/pages/WorkListPage/WorkListPage.test.tsx
@@ -29,9 +29,12 @@ function worker(
nationality_code: 'VN',
preferred_language: 'vi',
work_status: 'ACTIVE',
+ visa_type: null,
stay_expiry_date: null,
contract_start_date: null,
contract_end_date: null,
+ employment_permit_end_date: null,
+ employment_activity_end_date: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
version: 1,
@@ -175,7 +178,19 @@ function LocationProbe() {
function TaskDetailProbe() {
const { taskId } = useParams()
- return
업무 상세 {taskId}
+ const location = useLocation()
+ return (
+ <>
+
업무 상세 {taskId}
+
{`${location.pathname}${location.search}`}
+ >
+ )
+}
+
+function WorkCreateProbe() {
+ const location = useLocation()
+ const state = location.state as { workerId?: string; prefill?: string } | null
+ return
{`업무 생성 ${state?.workerId ?? ''} ${state?.prefill ?? ''}`}
}
function renderPage(initialEntry = '/tasks', { withToasts = false } = {}) {
@@ -193,8 +208,11 @@ function renderPage(initialEntry = '/tasks', { withToasts = false } = {}) {
}
/>
} />
-
업무 생성} />
+ } />
업무 검토 화면} />
+ 근로자 등록 화면} />
+ 근로자 상세 화면} />
+ 문서함 화면} />
,
)
@@ -301,11 +319,34 @@ describe('WorkListPage', () => {
expect(screen.queryByText('업무 상세 CASE-1')).not.toBeInTheDocument()
})
- it('shows a blocking error when the Case API fails', async () => {
+ it('keeps workers visible and marks the work connection as unavailable when the Case API fails', async () => {
mockApi({ cases: errorResponse('/api/v1/cases') })
renderPage()
- expect(await screen.findByText('업무 정보를 불러오지 못했습니다')).toBeInTheDocument()
+ expect(
+ await screen.findByText('근로자 목록은 표시했지만 업무 정보를 갱신하지 못했습니다.'),
+ ).toBeInTheDocument()
+ expect(screen.getByRole('listbox', { name: '업무 대상 근로자' })).toBeInTheDocument()
+ expect(screen.getByText('업무 정보를 확인하지 못했습니다')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '새 업무 요청' })).toBeDisabled()
+ })
+
+ it('shows a blocking error when neither workers nor cases can be loaded', async () => {
+ mockApi({
+ cases: errorResponse('/api/v1/cases'),
+ workers: errorResponse('/api/v1/workers'),
+ })
+ renderPage()
+
+ expect(await screen.findByText('업무함 정보를 불러오지 못했습니다')).toBeInTheDocument()
+ expect(screen.queryByRole('listbox', { name: '업무 대상 근로자' })).not.toBeInTheDocument()
+ })
+
+ it('shows a blocking error when cases are empty and workers cannot be loaded', async () => {
+ mockApi({ cases: casePageResponse([]), workers: errorResponse('/api/v1/workers') })
+ renderPage()
+
+ expect(await screen.findByText('업무함 정보를 불러오지 못했습니다')).toBeInTheDocument()
expect(screen.queryByRole('listbox', { name: '업무 대상 근로자' })).not.toBeInTheDocument()
})
@@ -317,14 +358,56 @@ describe('WorkListPage', () => {
expect(screen.getByText('근무 정보 확인 필요')).toBeInTheDocument()
})
- it('shows the empty work state and create action when there are no cases', async () => {
+ it('shows registered workers on the left and a no-work detail when there are no cases', async () => {
mockApi({ cases: casePageResponse([]) })
const user = userEvent.setup()
renderPage()
- expect(await screen.findByText('등록된 업무가 없습니다')).toBeInTheDocument()
- await user.click(screen.getByRole('button', { name: '업무 만들기' }))
- expect(await screen.findByText('업무 생성')).toBeInTheDocument()
+ expect(await screen.findByRole('option', { name: /응우옌 안.*업무 없음/ })).toBeInTheDocument()
+ expect(screen.getByText('현재 진행 중인 업무가 없습니다')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: '새 업무 요청' }))
+ expect(
+ await screen.findByText('업무 생성 W-1 응우옌 안 근로자의 업무를 준비해 주세요'),
+ ).toBeInTheDocument()
+ })
+
+ it('shows worker registration only when both workers and cases are empty', async () => {
+ mockApi({ workers: workerPageResponse([]), cases: casePageResponse([]) })
+ const user = userEvent.setup()
+ renderPage()
+
+ expect(await screen.findByText('등록된 근로자가 없습니다')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: '근로자 등록' }))
+ expect(await screen.findByText('근로자 등록 화면')).toBeInTheDocument()
+ })
+
+ it('filters the list to workers without active work', async () => {
+ mockApi({
+ workers: workerPageResponse([...WORKERS, worker('W-3', '김민지')]),
+ cases: casePageResponse(CASES),
+ })
+ const user = userEvent.setup()
+ renderPage()
+
+ await screen.findByRole('option', { name: /응우옌 안/ })
+ await user.click(screen.getByRole('button', { name: '업무 없음' }))
+
+ expect(screen.getByRole('option', { name: /김민지.*업무 없음/ })).toBeInTheDocument()
+ expect(screen.queryByRole('option', { name: /응우옌 안/ })).not.toBeInTheDocument()
+ })
+
+ it('opens worker details and documents from a no-work worker', async () => {
+ mockApi({ cases: casePageResponse([]) })
+ const user = userEvent.setup()
+ renderPage()
+
+ await user.click(await screen.findByRole('button', { name: '근로자 정보 보기' }))
+ expect(await screen.findByText('근로자 상세 화면')).toBeInTheDocument()
+
+ mockApi({ cases: casePageResponse([]) })
+ renderPage()
+ await user.click(await screen.findByRole('button', { name: '문서 확인' }))
+ expect(await screen.findByText('문서함 화면')).toBeInTheDocument()
})
it('discloses pagination caps when only part of the case list has loaded', async () => {
@@ -401,13 +484,14 @@ describe('WorkListPage', () => {
expect(await screen.findByText('업무 검토 화면')).toBeInTheDocument()
})
- it('shows a placeholder toast for "근거 보기"', async () => {
+ it('opens the Task detail context entry from "근거 보기"', async () => {
mockApi()
const user = userEvent.setup()
- renderPage('/tasks', { withToasts: true })
+ renderPage()
await user.click(await screen.findByRole('button', { name: '근거 보기' }))
- expect(screen.getByText('판단 근거 보기는 준비 중입니다.')).toBeInTheDocument()
+ expect(await screen.findByText('업무 상세 T-1')).toBeInTheDocument()
+ expect(screen.getByTestId('location')).toHaveTextContent('/tasks/T-1?context=open')
})
})
diff --git a/src/pages/WorkListPage/WorkListPage.tsx b/src/pages/WorkListPage/WorkListPage.tsx
index 62dd41c..3ee549e 100644
--- a/src/pages/WorkListPage/WorkListPage.tsx
+++ b/src/pages/WorkListPage/WorkListPage.tsx
@@ -10,7 +10,11 @@ import { useApiQuery } from '../../hooks/useApiQuery'
import { useDebouncedValue } from '../../hooks/useDebouncedValue'
import { WorkInboxDetail } from './WorkInboxDetail'
import { WorkInboxTargetList } from './WorkInboxTargetList'
-import { buildWorkInboxModel, type WorkInboxSort } from './workInboxModel'
+import {
+ buildWorkInboxModel,
+ type WorkInboxFilter,
+ type WorkInboxSort,
+} from './workInboxModel'
import styles from './WorkListPage.module.css'
const SORT_OPTIONS: { value: WorkInboxSort; label: string }[] = [
@@ -19,6 +23,12 @@ const SORT_OPTIONS: { value: WorkInboxSort; label: string }[] = [
{ value: 'worker-name', label: '정렬 · 근로자명' },
]
+const FILTER_OPTIONS: { value: WorkInboxFilter; label: string }[] = [
+ { value: 'all', label: '전체' },
+ { value: 'active', label: '진행 중' },
+ { value: 'no-work', label: '업무 없음' },
+]
+
function isCasePageEmpty(page: CasePageResponse): boolean {
return page.items.length === 0
}
@@ -28,6 +38,7 @@ export function WorkListPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [query, setQuery] = useState('')
const [sort, setSort] = useState('priority')
+ const [filter, setFilter] = useState('all')
const debouncedQuery = useDebouncedValue(query)
const selectedWorkerId = searchParams.get('workerId')
@@ -45,8 +56,9 @@ export function WorkListPage() {
query: debouncedQuery,
selectedWorkerId,
sort,
+ filter,
}),
- [casesQuery.data, workersQuery.data, debouncedQuery, selectedWorkerId, sort],
+ [casesQuery.data, workersQuery.data, debouncedQuery, selectedWorkerId, sort, filter],
)
const selectedGroup = inbox.selectedGroup
@@ -54,7 +66,6 @@ export function WorkListPage() {
useEffect(() => {
if (
- casesQuery.status !== 'success' ||
!selectedGroupId ||
selectedWorkerId === selectedGroupId
) {
@@ -63,7 +74,7 @@ export function WorkListPage() {
const nextParams = new URLSearchParams(searchParams)
nextParams.set('workerId', selectedGroupId)
setSearchParams(nextParams, { replace: true })
- }, [searchParams, selectedGroupId, selectedWorkerId, setSearchParams, casesQuery.status])
+ }, [searchParams, selectedGroupId, selectedWorkerId, setSearchParams])
function handleSelectWorker(workerId: string) {
const nextParams = new URLSearchParams(searchParams)
@@ -71,11 +82,28 @@ export function WorkListPage() {
setSearchParams(nextParams)
}
- const hasPaginationCap =
+ const hasCasePaginationCap =
(casesQuery.data?.total_elements ?? 0) > (casesQuery.data?.items.length ?? 0)
- const capNotice = hasPaginationCap
+ const hasWorkerPaginationCap =
+ (workersQuery.data?.total_elements ?? 0) > (workersQuery.data?.items.length ?? 0)
+ const capNotice = hasCasePaginationCap || hasWorkerPaginationCap
? '일부 데이터만 불러왔습니다. 검색·정렬·진행률은 현재 불러온 범위 기준입니다.'
: null
+ const isInitialLoading =
+ (casesQuery.status === 'loading' && casesQuery.data === null) ||
+ (inbox.allGroups.length === 0 && workersQuery.status === 'loading')
+ const isBlockingError =
+ inbox.allGroups.length === 0 &&
+ casesQuery.status !== 'loading' &&
+ workersQuery.status !== 'loading' &&
+ (casesQuery.status === 'error' || workersQuery.status === 'error')
+ const blockingError = casesQuery.error ?? workersQuery.error
+ const isTrueEmpty =
+ inbox.allGroups.length === 0 &&
+ casesQuery.status !== 'loading' &&
+ casesQuery.status !== 'error' &&
+ workersQuery.status === 'success'
+ const hasFilteredEmpty = inbox.allGroups.length > 0 && inbox.groups.length === 0
return (
@@ -92,17 +120,32 @@ export function WorkListPage() {
ariaLabel="근로자·업무 건·지금 할 일 검색"
className={styles.searchInput}
/>
-
setSort(value as WorkInboxSort)}
- ariaLabel="업무함 정렬"
- className={styles.sortDropdown}
- width="133px"
- />
+
+
+ {FILTER_OPTIONS.map((option) => (
+ setFilter(option.value)}
+ >
+ {option.label}
+
+ ))}
+
+
setSort(value as WorkInboxSort)}
+ ariaLabel="업무함 정렬"
+ className={styles.sortDropdown}
+ width="133px"
+ />
+
- {casesQuery.status === 'loading' && (
+ {isInitialLoading && (
)}
- {casesQuery.status === 'error' && (
+ {isBlockingError && (
)}
- {casesQuery.status === 'empty' && (
+ {isTrueEmpty && (
navigate('/tasks/new')}
+ title="등록된 근로자가 없습니다"
+ body="근로자를 먼저 등록하면 업무 유무와 진행 상태를 한곳에서 확인할 수 있습니다."
+ actionLabel="근로자 등록"
+ onAction={() => navigate('/workers')}
/>
)}
- {casesQuery.status === 'success' && inbox.groups.length === 0 && (
+ {hasFilteredEmpty && (
)}
- {casesQuery.status === 'success' && selectedGroup && (
+ {selectedGroup && !isInitialLoading && (
<>
+
+ {casesQuery.status === 'error' && (
+
+ 근로자 목록은 표시했지만 업무 정보를 갱신하지 못했습니다.
+
+ 다시 시도
+
+
+ )}
+ {workersQuery.status === 'error' && (
+
+ 근로자 상세 정보를 불러오지 못해 Case에 저장된 이름으로 표시합니다.
+
+ )}
+
navigate(`/tasks/${taskId}`)}
+ onOpenTaskContext={(taskId) => navigate(`/tasks/${taskId}?context=open`)}
+ onCreateWork={(workerId, workerDisplayName) =>
+ navigate('/tasks/new', {
+ state: {
+ prefill: `${workerDisplayName} 근로자의 업무를 준비해 주세요`,
+ workerId,
+ },
+ })
+ }
+ onOpenWorker={(workerId) => navigate(`/workers/${workerId}/detail`)}
+ onOpenDocuments={(workerId) => navigate(`/documents?workerId=${workerId}`)}
+ casesUnavailable={casesQuery.status === 'error'}
/>
diff --git a/src/pages/WorkListPage/workInboxModel.test.ts b/src/pages/WorkListPage/workInboxModel.test.ts
index f9a85b9..7d180a2 100644
--- a/src/pages/WorkListPage/workInboxModel.test.ts
+++ b/src/pages/WorkListPage/workInboxModel.test.ts
@@ -11,9 +11,12 @@ function worker(workerId: string, displayName = `근로자 ${workerId}`): Worker
nationality_code: 'VN',
preferred_language: 'vi',
work_status: 'ACTIVE',
+ visa_type: null,
stay_expiry_date: null,
contract_start_date: null,
contract_end_date: null,
+ employment_permit_end_date: null,
+ employment_activity_end_date: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
version: 1,
@@ -67,15 +70,20 @@ describe('workInboxModel', () => {
expect(model.groups.map((group) => group.workerId).sort()).toEqual(['W-1', 'W-2'])
})
- it('returns no worker groups when there are no cases', () => {
+ it('keeps registered workers visible when they have no cases', () => {
const model = buildWorkInboxModel({
workers: [worker('W-1')],
cases: [],
})
- expect(model.allGroups).toEqual([])
- expect(model.groups).toEqual([])
- expect(model.selectedGroup).toBeNull()
+ expect(model.groups).toHaveLength(1)
+ expect(model.groups[0]).toMatchObject({
+ workerId: 'W-1',
+ primaryCase: null,
+ activeCaseCount: 0,
+ historyCaseCount: 0,
+ })
+ expect(model.selectedGroup?.workerId).toBe('W-1')
})
it('keeps worker as null when /workers does not include the case owner, without dropping the case', () => {
@@ -100,7 +108,7 @@ describe('workInboxModel', () => {
})
expect(model.groups[0].cases).toHaveLength(3)
- expect(model.groups[0].primaryCase.case_id).toBe('CASE-2')
+ expect(model.groups[0].primaryCase?.case_id).toBe('CASE-2')
})
it('ranks cases by priority, due date, then case id', () => {
@@ -132,6 +140,38 @@ describe('workInboxModel', () => {
expect(model.groups.map((group) => group.workerId)).toEqual(['W-2', 'W-1', 'W-3'])
})
+ it('places workers with active work before workers with only history or no cases', () => {
+ const model = buildWorkInboxModel({
+ workers: [worker('W-1'), worker('W-2'), worker('W-3')],
+ cases: [
+ caseSummary('C-1', 'W-1', undefined, { priority: 'LOW' }),
+ caseSummary('C-2', 'W-2', undefined, {
+ priority: 'URGENT',
+ display_status: 'COMPLETED',
+ }),
+ ],
+ })
+
+ expect(model.groups.map((group) => group.workerId)).toEqual(['W-1', 'W-2', 'W-3'])
+ expect(model.groups[1]).toMatchObject({ activeCaseCount: 0, historyCaseCount: 1 })
+ })
+
+ it('filters active and no-work workers without dropping completed history', () => {
+ const input = {
+ workers: [worker('W-1'), worker('W-2'), worker('W-3')],
+ cases: [
+ caseSummary('C-1', 'W-1'),
+ caseSummary('C-2', 'W-2', undefined, { display_status: 'CANCELLED' as const }),
+ ],
+ }
+
+ const active = buildWorkInboxModel({ ...input, filter: 'active' })
+ const noWork = buildWorkInboxModel({ ...input, filter: 'no-work' })
+
+ expect(active.groups.map((group) => group.workerId)).toEqual(['W-1'])
+ expect(noWork.groups.map((group) => group.workerId)).toEqual(['W-2', 'W-3'])
+ })
+
it('normalizes Unicode, letter case, and whitespace when searching all supported fields', () => {
const workers = [worker('W-1', '김 민지'), worker('W-2', 'Nguyen An')]
const cases = [
@@ -153,6 +193,16 @@ describe('workInboxModel', () => {
expect(workerNameResult.groups.map((group) => group.workerId)).toEqual(['W-2'])
})
+ it('searches registered workers even when they do not have cases', () => {
+ const model = buildWorkInboxModel({
+ workers: [worker('W-1', '김민지'), worker('W-2', '응우옌 안')],
+ cases: [],
+ query: '응우옌',
+ })
+
+ expect(model.groups.map((group) => group.workerId)).toEqual(['W-2'])
+ })
+
it('supports deterministic due-date and worker-name sorting', () => {
const input = {
workers: [worker('W-1', '파티마'), worker('W-2', '김민지')],
diff --git a/src/pages/WorkListPage/workInboxModel.ts b/src/pages/WorkListPage/workInboxModel.ts
index 8d09dca..ca1f68a 100644
--- a/src/pages/WorkListPage/workInboxModel.ts
+++ b/src/pages/WorkListPage/workInboxModel.ts
@@ -18,10 +18,14 @@ export interface WorkInboxWorkerGroup {
/** /workers 응답에서 찾은 근로자 상세 — 조회 범위 밖이면 null이다. */
worker: WorkerResponse | null
cases: CaseSummaryResponse[]
- primaryCase: CaseSummaryResponse
+ /** 진행 중인 Case를 우선하고, 없으면 우선순위가 가장 높은 이력 Case를 사용한다. */
+ primaryCase: CaseSummaryResponse | null
+ activeCaseCount: number
+ historyCaseCount: number
}
export type WorkInboxSort = 'priority' | 'due-date' | 'worker-name'
+export type WorkInboxFilter = 'all' | 'active' | 'no-work'
export interface BuildWorkInboxModelInput {
cases: readonly CaseSummaryResponse[]
@@ -29,6 +33,7 @@ export interface BuildWorkInboxModelInput {
query?: string
selectedWorkerId?: string | null
sort?: WorkInboxSort
+ filter?: WorkInboxFilter
}
export interface WorkInboxModel {
@@ -51,6 +56,10 @@ function dueDateSortValue(dueDate: string | null): number {
return Number.isNaN(timestamp) ? Number.POSITIVE_INFINITY : timestamp
}
+export function isActiveWorkInboxCase(item: CaseSummaryResponse): boolean {
+ return item.display_status !== 'COMPLETED' && item.display_status !== 'CANCELLED'
+}
+
export function compareWorkInboxCases(left: CaseSummaryResponse, right: CaseSummaryResponse): number {
const priorityDifference =
WORK_INBOX_CASE_PRIORITY_ORDER[left.priority] - WORK_INBOX_CASE_PRIORITY_ORDER[right.priority]
@@ -69,16 +78,30 @@ export function normalizeWorkInboxSearch(value: string): string {
}
function compareWorkerGroups(left: WorkInboxWorkerGroup, right: WorkInboxWorkerGroup): number {
- const primaryDifference = compareWorkInboxCases(left.primaryCase, right.primaryCase)
- return primaryDifference || compareStableText(left.workerId, right.workerId)
+ const activityDifference = Number(right.activeCaseCount > 0) - Number(left.activeCaseCount > 0)
+ if (activityDifference !== 0) return activityDifference
+
+ if (left.primaryCase && right.primaryCase) {
+ const primaryDifference = compareWorkInboxCases(left.primaryCase, right.primaryCase)
+ if (primaryDifference !== 0) return primaryDifference
+ } else if (left.primaryCase) {
+ return -1
+ } else if (right.primaryCase) {
+ return 1
+ }
+
+ return compareStableText(left.workerId, right.workerId)
}
function compareWorkerGroupsByDueDate(
left: WorkInboxWorkerGroup,
right: WorkInboxWorkerGroup,
): number {
- const leftDueDate = dueDateSortValue(left.primaryCase.due_date)
- const rightDueDate = dueDateSortValue(right.primaryCase.due_date)
+ const activityDifference = Number(right.activeCaseCount > 0) - Number(left.activeCaseCount > 0)
+ if (activityDifference !== 0) return activityDifference
+
+ const leftDueDate = dueDateSortValue(left.primaryCase?.due_date ?? null)
+ const rightDueDate = dueDateSortValue(right.primaryCase?.due_date ?? null)
if (leftDueDate < rightDueDate) return -1
if (leftDueDate > rightDueDate) return 1
return compareWorkerGroups(left, right)
@@ -105,6 +128,8 @@ function matchesQuery(group: WorkInboxWorkerGroup, normalizedQuery: string): boo
const searchableText = normalizeWorkInboxSearch(
[
group.workerDisplayName,
+ group.worker?.nationality_code ?? '',
+ group.worker?.visa_type ?? '',
...group.cases.flatMap((item) => [item.title, item.current_task?.title ?? '']),
].join(' '),
)
@@ -112,12 +137,19 @@ function matchesQuery(group: WorkInboxWorkerGroup, normalizedQuery: string): boo
return queryTokens.every((token) => searchableText.includes(token))
}
+function matchesFilter(group: WorkInboxWorkerGroup, filter: WorkInboxFilter): boolean {
+ if (filter === 'active') return group.activeCaseCount > 0
+ if (filter === 'no-work') return group.activeCaseCount === 0
+ return true
+}
+
export function buildWorkInboxModel({
cases,
workers,
query = '',
selectedWorkerId,
sort = 'priority',
+ filter = 'all',
}: BuildWorkInboxModelInput): WorkInboxModel {
const workerById = new Map(workers.map((worker) => [worker.worker_id, worker]))
const casesByWorkerId = new Map()
@@ -128,22 +160,39 @@ export function buildWorkInboxModel({
casesByWorkerId.set(item.worker_id, workerCases)
}
+ const workerIds = new Set([
+ ...workers.map((worker) => worker.worker_id),
+ ...cases.map((item) => item.worker_id),
+ ])
+
const allGroups = sortWorkInboxGroups(
- [...casesByWorkerId.entries()].map(([workerId, workerCases]) => {
- const sortedCases = [...workerCases].sort(compareWorkInboxCases)
+ [...workerIds].map((workerId) => {
+ const worker = workerById.get(workerId) ?? null
+ const workerCases = casesByWorkerId.get(workerId) ?? []
+ const sortedCases = [...workerCases].sort((left, right) => {
+ const activityDifference =
+ Number(isActiveWorkInboxCase(right)) - Number(isActiveWorkInboxCase(left))
+ return activityDifference || compareWorkInboxCases(left, right)
+ })
+ const activeCaseCount = sortedCases.filter(isActiveWorkInboxCase).length
return {
workerId,
- workerDisplayName: sortedCases[0].worker_display_name,
- worker: workerById.get(workerId) ?? null,
+ workerDisplayName:
+ worker?.display_name ?? sortedCases[0]?.worker_display_name ?? `근로자 ${workerId}`,
+ worker,
cases: sortedCases,
- primaryCase: sortedCases[0],
+ primaryCase: sortedCases[0] ?? null,
+ activeCaseCount,
+ historyCaseCount: sortedCases.length - activeCaseCount,
}
}),
sort,
)
const normalizedQuery = normalizeWorkInboxSearch(query)
- const groups = allGroups.filter((group) => matchesQuery(group, normalizedQuery))
+ const groups = allGroups.filter(
+ (group) => matchesQuery(group, normalizedQuery) && matchesFilter(group, filter),
+ )
const requestedSelection = selectedWorkerId?.trim()
const selectedGroup =
groups.find((group) => group.workerId === requestedSelection) ?? groups[0] ?? null
diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx b/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx
index 3e22cde..d7a3694 100644
--- a/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx
+++ b/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx
@@ -29,9 +29,12 @@ function worker(overrides: Partial = {}): WorkerResponse {
nationality_code: 'VN',
preferred_language: 'vi',
work_status: 'ACTIVE',
+ visa_type: null,
stay_expiry_date: null,
contract_start_date: null,
contract_end_date: null,
+ employment_permit_end_date: null,
+ employment_activity_end_date: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
version: 1,
@@ -110,11 +113,18 @@ afterEach(() => {
describe('WorkerDetailPage', () => {
it('renders basic profile info for the selected worker', async () => {
- mockWorkerAndDocuments()
+ mockWorkerAndDocuments({
+ visa_type: 'E-9',
+ employment_permit_end_date: '2028-03-01',
+ employment_activity_end_date: '2028-04-01',
+ })
renderPage('W-018')
expect(await screen.findByRole('heading', { name: '쩐티B' })).toBeInTheDocument()
expect(screen.getByText('VN')).toBeInTheDocument()
+ expect(screen.getAllByText('E-9').length).toBeGreaterThan(0)
+ expect(screen.getByText(/2028\.03\.01/)).toBeInTheDocument()
+ expect(screen.getByText(/2028\.04\.01/)).toBeInTheDocument()
expect(screen.getAllByText('준비 중').length).toBeGreaterThan(0)
})
@@ -251,4 +261,20 @@ describe('WorkerDetailPage', () => {
expect(patchCall).toBeDefined()
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 () => {
+ const user = userEvent.setup()
+ mockWorkerAndDocuments({ employment_permit_end_date: '2028-03-01' })
+ renderPage('W-018')
+ await screen.findByRole('heading', { name: '쩐티B' })
+
+ await user.click(screen.getByRole('button', { name: '정보 수정' }))
+ await user.clear(screen.getByLabelText('고용허가 종료일'))
+ await user.click(screen.getByRole('button', { name: '저장' }))
+
+ expect(
+ 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 0513695..9e5287d 100644
--- a/src/pages/WorkerDetailPage/WorkerDetailPage.tsx
+++ b/src/pages/WorkerDetailPage/WorkerDetailPage.tsx
@@ -13,9 +13,6 @@ import { getOperationalDateViewModel } from '../../view-models/dateViewModel'
import { RegisterDocumentModal } from './overlays/RegisterDocumentModal'
import styles from './WorkerDetailPage.module.css'
-// 제품이 E-9(비전문취업) 근로자를 대상으로 하는 만큼 비자 유형은 항상 E-9다.
-const VISA_TYPE = 'E-9'
-
export function WorkerDetailPage() {
const { workerId } = useParams()
@@ -61,6 +58,15 @@ export function WorkerDetailPage() {
const stayExpiry = getOperationalDateViewModel('STAY_EXPIRY', worker.stay_expiry_date)
const contractStart = getOperationalDateViewModel('CONTRACT_START', worker.contract_start_date)
const contractEnd = getOperationalDateViewModel('CONTRACT_END', worker.contract_end_date)
+ const employmentPermitEnd = getOperationalDateViewModel(
+ 'EMPLOYMENT_PERMIT_END',
+ worker.employment_permit_end_date,
+ )
+ const employmentActivityEnd = getOperationalDateViewModel(
+ 'EMPLOYMENT_ACTIVITY_END',
+ worker.employment_activity_end_date,
+ )
+ const visaType = worker.visa_type ?? '미등록'
return (
@@ -77,7 +83,7 @@ export function WorkerDetailPage() {
)}
- {worker.nationality_code} · {VISA_TYPE} | 연락처·사번 준비 중
+ {worker.nationality_code} · {visaType} | 연락처·사번 준비 중
@@ -88,7 +94,7 @@ export function WorkerDetailPage() {
-
+
{/* TODO(#48): worker_sensitive_data API 연동 후 사번·연락처 표시 */}
@@ -99,6 +105,8 @@ export function WorkerDetailPage() {
/>
+
+
diff --git a/src/pages/WorkerListPage/WorkerListPage.test.tsx b/src/pages/WorkerListPage/WorkerListPage.test.tsx
index dfd50b5..7358b2d 100644
--- a/src/pages/WorkerListPage/WorkerListPage.test.tsx
+++ b/src/pages/WorkerListPage/WorkerListPage.test.tsx
@@ -23,9 +23,12 @@ function worker(overrides: Partial): WorkerResponse {
nationality_code: 'VN',
preferred_language: 'vi',
work_status: 'ACTIVE',
+ visa_type: null,
stay_expiry_date: null,
contract_start_date: null,
contract_end_date: null,
+ employment_permit_end_date: null,
+ employment_activity_end_date: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
version: 1,
@@ -235,6 +238,7 @@ describe('WorkerListPage', () => {
items: [
{
task_id: 'T-1',
+ target_type: 'WORKER',
worker_id: 'W-021',
case_id: null,
task_type: 'STAY_PERIOD_EXTENSION',
diff --git a/src/pages/WorkerListPage/WorkerListPage.tsx b/src/pages/WorkerListPage/WorkerListPage.tsx
index cb0fb20..73973da 100644
--- a/src/pages/WorkerListPage/WorkerListPage.tsx
+++ b/src/pages/WorkerListPage/WorkerListPage.tsx
@@ -13,7 +13,7 @@ import { SearchInput } from '../../components/ui/SearchInput/SearchInput'
import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel'
import { useApiQuery } from '../../hooks/useApiQuery'
import { useDebouncedValue } from '../../hooks/useDebouncedValue'
-import { AUDIT_ACTION_LABEL } from '../../utils/auditLabels'
+import { getAuditActionLabel } from '../../utils/auditLabels'
import { formatEventTime } from '../../utils/datetime'
import { TASK_STATUS_LABEL, TASK_STATUS_NEXT_ACTION } from '../../utils/taskStatus'
import { daysUntil, getUrgencyTier, URGENCY_TONE } from '../../utils/urgency'
@@ -68,9 +68,9 @@ function etaFor(task: TaskSummaryResponse | null): string {
const PRIORITY_COUNT = 5
-// 제품이 E-9(비전문취업) 근로자를 대상으로 하는 만큼 비자 유형은 항상 E-9다.
-// WorkerResponse에는 별도 visa_type 필드가 없다.
-const VISA_TYPE = 'E-9'
+function visaTypeLabel(worker: WorkerResponse): string {
+ return worker.visa_type ?? '비자 미등록'
+}
function toRow(worker: WorkerResponse) {
const deadlineDays = daysUntil(worker.stay_expiry_date)
@@ -103,6 +103,7 @@ export function WorkerListPage() {
const tasksByWorker = useMemo(() => {
const map = new Map()
for (const task of taskPage?.items ?? []) {
+ if (!task.worker_id) continue
const list = map.get(task.worker_id) ?? []
list.push(task)
map.set(task.worker_id, list)
@@ -264,7 +265,7 @@ export function WorkerListPage() {
- {row.worker.nationality_code} · {VISA_TYPE}
+ {row.worker.nationality_code} · {visaTypeLabel(row.worker)}
))
@@ -285,7 +286,7 @@ export function WorkerListPage() {
)}
- {selectedRow.worker.nationality_code} · {VISA_TYPE} | 연락처·사번 준비 중
+ {selectedRow.worker.nationality_code} · {visaTypeLabel(selectedRow.worker)} | 연락처·사번 준비 중
@@ -362,7 +363,7 @@ export function WorkerListPage() {
{formatEventTime(event.created_at)}
- {event.change_summary ?? AUDIT_ACTION_LABEL[event.action]}
+ {event.change_summary ?? getAuditActionLabel(event.action)}
))}
diff --git a/src/utils/auditLabels.test.ts b/src/utils/auditLabels.test.ts
new file mode 100644
index 0000000..6e86c60
--- /dev/null
+++ b/src/utils/auditLabels.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import { getAuditActionLabel } from './auditLabels'
+
+describe('getAuditActionLabel', () => {
+ it('labels the extended server audit actions', () => {
+ expect(getAuditActionLabel('AI_RUN_CANDIDATES_DECIDED')).toBe(
+ 'Agent 업무 후보를 확정했습니다.',
+ )
+ expect(getAuditActionLabel('WORKER_LINK_RESPONSES_REVIEWED')).toBe(
+ '근로자 응답을 확인했습니다.',
+ )
+ expect(getAuditActionLabel('WORKER_LINK_SENT')).toBe(
+ '근로자에게 모바일 링크를 전달했습니다.',
+ )
+ })
+
+ it('shows a safe fallback for a future server action', () => {
+ expect(getAuditActionLabel('NEW_SERVER_EVENT')).toBe(
+ '시스템 이벤트(NEW_SERVER_EVENT)가 기록되었습니다.',
+ )
+ })
+})
diff --git a/src/utils/auditLabels.ts b/src/utils/auditLabels.ts
index d850bd1..76e9397 100644
--- a/src/utils/auditLabels.ts
+++ b/src/utils/auditLabels.ts
@@ -24,4 +24,23 @@ export const AUDIT_ACTION_LABEL: Record = {
EXTERNAL_SUBMISSION_RECORDED: '외부기관 제출을 기록했습니다.',
EVIDENCE_RECORDED: '완료 증빙을 기록했습니다.',
TASK_COMPLETED: '업무를 완료 처리했습니다.',
+ FILE_UPLOADED: '파일을 업로드했습니다.',
+ FILE_DOWNLOADED: '파일을 내려받았습니다.',
+ WORKER_DOCUMENT_FILE_LINKED: '근로자 문서에 파일을 연결했습니다.',
+ DOCUMENT_REQUEST_DRAFT_SAVED: '문서 요청 초안을 저장했습니다.',
+ AI_RUN_CREATED: 'Agent 업무 분석을 시작했습니다.',
+ AI_RUN_ANSWERS_SUBMITTED: 'Agent의 추가 질문에 답변했습니다.',
+ AI_RUN_CANDIDATES_DECIDED: 'Agent 업무 후보를 확정했습니다.',
+ OUTBOX_MANUAL_RETRY_REQUESTED: '실패한 이벤트의 재처리를 요청했습니다.',
+ WORKER_LINK_RESPONSE_SUBMITTED: '근로자가 모바일 링크로 응답했습니다.',
+ WORKER_LINK_RESPONSES_REVIEWED: '근로자 응답을 확인했습니다.',
+ WORKER_LINK_SENT: '근로자에게 모바일 링크를 전달했습니다.',
+ WORKER_LINK_ACCESSED: '근로자가 모바일 링크를 열었습니다.',
+ USER_AGREEMENTS_RECORDED: '사용자 약관 동의를 기록했습니다.',
+ PASSWORD_RESET_REQUESTED: '비밀번호 재설정을 요청했습니다.',
+ PASSWORD_RESET_COMPLETED: '비밀번호 재설정을 완료했습니다.',
+}
+
+export function getAuditActionLabel(action: string): string {
+ return AUDIT_ACTION_LABEL[action as AuditAction] ?? `시스템 이벤트(${action})가 기록되었습니다.`
}
diff --git a/src/utils/taskStatus.ts b/src/utils/taskStatus.ts
index 716ac46..9dbd975 100644
--- a/src/utils/taskStatus.ts
+++ b/src/utils/taskStatus.ts
@@ -41,6 +41,11 @@ export const TASK_TYPE_LABEL: Record = {
RECONTRACT: '재계약',
EMPLOYMENT_PERIOD_EXTENSION: '고용기간 연장',
STAY_PERIOD_EXTENSION: '체류기간 연장',
+ DOCUMENT_REQUEST: '서류 요청',
+ WORKER_ONBOARDING: '신규 근로자 등록',
+ PAYROLL_EXPLANATION: '급여명세서 설명',
+ EMPLOYMENT_CHANGE: '고용변동 신고',
+ WORK_INSTRUCTION: '업무·근무일정 안내',
}
export const TASK_SOURCE_LABEL: Record = {
diff --git a/src/view-models/dateViewModel.test.ts b/src/view-models/dateViewModel.test.ts
index 8e91af6..b738fec 100644
--- a/src/view-models/dateViewModel.test.ts
+++ b/src/view-models/dateViewModel.test.ts
@@ -9,6 +9,9 @@ describe('getOperationalDateViewModel', () => {
missing: true,
})
expect(getOperationalDateViewModel('TASK_DUE', null).display).toBe('업무 마감일 미등록')
+ expect(getOperationalDateViewModel('EMPLOYMENT_PERMIT_END', null).display).toBe(
+ '고용허가 종료일 미등록',
+ )
})
it('formats a document expiry date with a relative deadline', () => {
diff --git a/src/view-models/dateViewModel.ts b/src/view-models/dateViewModel.ts
index 57c4697..c7beb0d 100644
--- a/src/view-models/dateViewModel.ts
+++ b/src/view-models/dateViewModel.ts
@@ -6,6 +6,8 @@ export type OperationalDateKind =
| 'STAY_EXPIRY'
| 'CONTRACT_START'
| 'CONTRACT_END'
+ | 'EMPLOYMENT_PERMIT_END'
+ | 'EMPLOYMENT_ACTIVITY_END'
| 'DOCUMENT_EXPIRY'
export interface OperationalDateViewModel {
@@ -24,6 +26,8 @@ const DATE_LABEL: Record = {
STAY_EXPIRY: '체류 만료일',
CONTRACT_START: '근로계약 시작일',
CONTRACT_END: '근로계약 종료일',
+ EMPLOYMENT_PERMIT_END: '고용허가 종료일',
+ EMPLOYMENT_ACTIVITY_END: '취업활동 종료일',
DOCUMENT_EXPIRY: '문서 만료일',
}
diff --git a/src/view-models/workerRequestStateViewModel.test.ts b/src/view-models/workerRequestStateViewModel.test.ts
index b05b74a..03e9c37 100644
--- a/src/view-models/workerRequestStateViewModel.test.ts
+++ b/src/view-models/workerRequestStateViewModel.test.ts
@@ -8,6 +8,9 @@ describe('getWorkerRequestStateViewModel', () => {
expect(getWorkerRequestStateViewModel({
requestSentAt: '2026-08-04T01:00:00Z', responseReceivedAt: '2026-08-04T02:00:00Z',
}).label).toBe('승인대기')
+ expect(getWorkerRequestStateViewModel({
+ responseReceivedAt: '2026-08-04T02:00:00Z', responseReadAt: '2026-08-04T03:00:00Z',
+ })).toMatchObject({ label: '요청전송', description: expect.stringContaining('후속 업무') })
expect(getWorkerRequestStateViewModel({ completedAt: '2026-08-04T03:00:00Z' }).label).toBe('완료')
})
})
diff --git a/src/view-models/workerRequestStateViewModel.ts b/src/view-models/workerRequestStateViewModel.ts
index c426107..5a8282d 100644
--- a/src/view-models/workerRequestStateViewModel.ts
+++ b/src/view-models/workerRequestStateViewModel.ts
@@ -22,6 +22,13 @@ export function getWorkerRequestStateViewModel(
if (source.responseReceivedAt && !source.responseReadAt) {
return { state: 'APPROVAL_WAITING', label: '승인대기', description: '근로자 응답이 도착해 담당자 확인이 필요합니다.' }
}
+ if (source.responseReceivedAt && source.responseReadAt) {
+ return {
+ state: 'REQUEST_SENT',
+ label: '요청전송',
+ description: '근로자 응답을 확인했습니다. 필요한 후속 업무를 진행해 주세요.',
+ }
+ }
if (source.requestSentAt) {
return { state: 'REQUEST_SENT', label: '요청전송', description: '요청을 전송했으며 근로자 응답을 기다립니다.' }
}