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' ? (
@@ -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)}

+
+ +
+ +
+
+ + {casesUnavailable ? '업무 연결 확인 필요' : '업무 없음'} + +

{emptyTitle}

+

{emptyBody}

+
+
+ + + +
+
+
+ ) + } - 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) { @@ -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 && ( @@ -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) => ( + + ))} +
+ 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: '문서 만료일', }