diff --git a/src/api/approvals.test.ts b/src/api/approvals.test.ts
index 3f6a801..21907dd 100644
--- a/src/api/approvals.test.ts
+++ b/src/api/approvals.test.ts
@@ -18,7 +18,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 +35,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'],
diff --git a/src/api/approvals.ts b/src/api/approvals.ts
index 1df9ca2..db94e4e 100644
--- a/src/api/approvals.ts
+++ b/src/api/approvals.ts
@@ -48,6 +48,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,
diff --git a/src/api/audit.ts b/src/api/audit.ts
index c56685a..badba7b 100644
--- a/src/api/audit.ts
+++ b/src/api/audit.ts
@@ -15,8 +15,32 @@ 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_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'
+ | 'USER_ACCOUNT'
export interface AuditEventResponse {
audit_event_id: string
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/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',
diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx
index a469628..a564c83 100644
--- a/src/pages/CaseDetailPage/CaseDetailPage.tsx
+++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx
@@ -28,7 +28,7 @@ import { StatusLabel, type StatusTone } from '../../components/ui/StatusLabel/St
import { Tabs } from '../../components/ui/Tabs/Tabs'
import { useApiQuery } from '../../hooks/useApiQuery'
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'
@@ -746,7 +746,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)}
@@ -877,7 +877,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)}
))}
diff --git a/src/pages/DashboardPage/DashboardPage.test.tsx b/src/pages/DashboardPage/DashboardPage.test.tsx
index 57356fb..1b60938 100644
--- a/src/pages/DashboardPage/DashboardPage.test.tsx
+++ b/src/pages/DashboardPage/DashboardPage.test.tsx
@@ -30,6 +30,7 @@ function task(
): TaskSummaryResponse {
return {
task_id: taskId,
+ target_type: 'WORKER',
worker_id: 'W-1',
case_id: null,
task_type: 'STAY_PERIOD_EXTENSION',
diff --git a/src/pages/DocumentListPage/DocumentListPage.module.css b/src/pages/DocumentListPage/DocumentListPage.module.css
index d26dc38..3e350db 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..fde3621 100644
--- a/src/pages/WorkListPage/WorkInboxDetail.tsx
+++ b/src/pages/WorkListPage/WorkInboxDetail.tsx
@@ -12,11 +12,16 @@ import {
getWorkflowLabel,
isReviewCase,
} from './workInboxPresentation'
+import { isActiveWorkInboxCase } from './workInboxModel'
import styles from './WorkListPage.module.css'
interface WorkInboxDetailProps {
group: WorkInboxWorkerGroup
onOpenTask: (taskId: string) => void
+ onCreateWork: (workerId: string, workerDisplayName: string) => void
+ onOpenWorker: (workerId: string) => void
+ onOpenDocuments: (workerId: string) => void
+ casesUnavailable?: boolean
}
const NATIONALITY_LABEL: Record
= {
@@ -29,44 +34,113 @@ 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) {
+export function WorkInboxDetail({
+ group,
+ onOpenTask,
+ onCreateWork,
+ onOpenWorker,
+ onOpenDocuments,
+ casesUnavailable = false,
+}: WorkInboxDetailProps) {
const showToast = useToastStore((state) => state.showToast)
- const [activeCaseId, setActiveCaseId] = useState(group.primaryCase.case_id)
+ 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('근로자 업무 메뉴는 준비 중입니다.')
- }
-
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 열기 →
@@ -195,7 +269,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 98d925a..303ce84 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);
@@ -295,6 +359,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);
@@ -638,11 +753,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;
}
@@ -651,6 +782,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..dd99601 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,
@@ -178,6 +181,12 @@ function TaskDetailProbe() {
return
업무 상세 {taskId}
}
+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 } = {}) {
render(
@@ -193,8 +202,11 @@ function renderPage(initialEntry = '/tasks', { withToasts = false } = {}) {
}
/>
} />
- 업무 생성} />
+ } />
업무 검토 화면} />
+ 근로자 등록 화면} />
+ 근로자 상세 화면} />
+ 문서함 화면} />
,
)
@@ -301,11 +313,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 +352,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 () => {
diff --git a/src/pages/WorkListPage/WorkListPage.tsx b/src/pages/WorkListPage/WorkListPage.tsx
index 62dd41c..f6490a1 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}`)}
+ 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..2a48d6f
--- /dev/null
+++ b/src/utils/auditLabels.test.ts
@@ -0,0 +1,19 @@
+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(
+ '근로자 응답을 확인했습니다.',
+ )
+ })
+
+ 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..f7f7e64 100644
--- a/src/utils/auditLabels.ts
+++ b/src/utils/auditLabels.ts
@@ -24,4 +24,22 @@ 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_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: '문서 만료일',
}