diff --git a/src/api/approvals.test.ts b/src/api/approvals.test.ts index 21907dd..c6a48c8 100644 --- a/src/api/approvals.test.ts +++ b/src/api/approvals.test.ts @@ -4,6 +4,7 @@ import { approveTask, buildTaskApprovalSnapshot, completeTask, + recordExternalSubmission, recordTaskEvidence, rejectTask, requestTaskApproval, @@ -43,12 +44,17 @@ describe('approval APIs', () => { }) }) - it('uses the approval, decision, evidence and completion endpoints', async () => { + it('uses the approval, decision, external submission, evidence and completion endpoints', async () => { vi.mocked(fetch).mockImplementation(() => Promise.resolve(jsonResponse({ task_id: 'T-1' }, 201))) await requestTaskApproval('T-1', buildTaskApprovalSnapshot(task())) await approveTask('T-1', { expected_version: 8 }) await rejectTask('T-1', { expected_version: 8, reason: '마감일 확인 필요' }) + await recordExternalSubmission('T-1', { + expected_version: 8, + destination: '수원출입국·외국인청', + safe_reference: '접수번호 1234', + }) await recordTaskEvidence('T-1', { evidence_type: 'RECEIPT', note: '접수번호 1234' }) await completeTask('T-1', 9) @@ -56,8 +62,14 @@ describe('approval APIs', () => { expect(String(calls[0][0])).toContain('/tasks/T-1/approval-requests') expect(String(calls[1][0])).toContain('/tasks/T-1/approve') expect(String(calls[2][0])).toContain('/tasks/T-1/reject') - expect(String(calls[3][0])).toContain('/tasks/T-1/evidence') - expect(String(calls[4][0])).toContain('/tasks/T-1/complete') - expect(JSON.parse(calls[4][1]?.body as string)).toEqual({ expected_version: 9 }) + expect(String(calls[3][0])).toContain('/tasks/T-1/external-submissions') + expect(JSON.parse(calls[3][1]?.body as string)).toEqual({ + expected_version: 8, + destination: '수원출입국·외국인청', + safe_reference: '접수번호 1234', + }) + expect(String(calls[4][0])).toContain('/tasks/T-1/evidence') + expect(String(calls[5][0])).toContain('/tasks/T-1/complete') + expect(JSON.parse(calls[5][1]?.body as string)).toEqual({ expected_version: 9 }) }) }) diff --git a/src/api/approvals.ts b/src/api/approvals.ts index db94e4e..14293d1 100644 --- a/src/api/approvals.ts +++ b/src/api/approvals.ts @@ -36,6 +36,13 @@ export interface RecordTaskEvidenceBody { recorded_at?: string } +export interface RecordExternalSubmissionBody { + expected_version: number + destination: string + safe_reference: string + submitted_at?: string +} + export interface TaskActionResponse { resource_id: string task_id: string @@ -105,6 +112,16 @@ export function recordTaskEvidence( }) } +export function recordExternalSubmission( + taskId: string, + body: RecordExternalSubmissionBody, +): Promise { + return apiFetch( + `/tasks/${encodeURIComponent(taskId)}/external-submissions`, + { method: 'POST', body: JSON.stringify(body) }, + ) +} + export function completeTask(taskId: string, expectedVersion: number): Promise { return apiFetch(`/tasks/${encodeURIComponent(taskId)}/complete`, { method: 'POST', diff --git a/src/api/audit.ts b/src/api/audit.ts index badba7b..bbde47b 100644 --- a/src/api/audit.ts +++ b/src/api/audit.ts @@ -25,6 +25,7 @@ export type AuditAction = | 'OUTBOX_MANUAL_RETRY_REQUESTED' | 'WORKER_LINK_RESPONSE_SUBMITTED' | 'WORKER_LINK_RESPONSES_REVIEWED' + | 'WORKER_LINK_SENT' | 'WORKER_LINK_ACCESSED' | 'USER_AGREEMENTS_RECORDED' | 'PASSWORD_RESET_REQUESTED' @@ -40,6 +41,7 @@ export type AuditTargetType = | 'DOCUMENT_REQUEST_DRAFT' | 'AI_RUN' | 'OUTBOX_EVENT' + | 'WORKER_LINK' | 'USER_ACCOUNT' export interface AuditEventResponse { diff --git a/src/api/cases.test.ts b/src/api/cases.test.ts new file mode 100644 index 0000000..baa9770 --- /dev/null +++ b/src/api/cases.test.ts @@ -0,0 +1,27 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchCaseProjection, fetchCases } from './cases' + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} + +beforeEach(() => vi.stubGlobal('fetch', vi.fn())) +afterEach(() => vi.unstubAllGlobals()) + +describe('case APIs', () => { + it('lists Cases with query parameters and fetches a projection by encoded ID', async () => { + vi.mocked(fetch).mockImplementation(() => + Promise.resolve(jsonResponse({ items: [], page: 0, size: 20, total_elements: 0 })), + ) + + await fetchCases({ keyword: '응웬 반', page: 1, size: 20 }) + await fetchCaseProjection('C/1') + + const calls = vi.mocked(fetch).mock.calls + expect(String(calls[0][0])).toContain('/cases?keyword=%EC%9D%91%EC%9B%AC+%EB%B0%98&page=1&size=20') + expect(String(calls[1][0])).toContain('/cases/C%2F1/projection') + }) +}) diff --git a/src/api/workerLinks.test.ts b/src/api/workerLinks.test.ts index de7590b..957c620 100644 --- a/src/api/workerLinks.test.ts +++ b/src/api/workerLinks.test.ts @@ -1,7 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + fetchTaskWorkerLinkDelivery, + fetchTaskWorkerResponses, fetchWorkerLink, issueWorkerLink, + markWorkerLinkSent, + markTaskWorkerResponsesRead, resolveWorkerPortalUrl, submitWorkerResponse, uploadWorkerLinkDocument, @@ -24,6 +28,37 @@ describe('worker link APIs', () => { expect(new Headers(init?.headers).get('Idempotency-Key')).toBe('issue-1') }) + it('gets the current delivery status and records manual delivery', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + jsonResponse({ + worker_link_id: 'L-1', + link_status: 'ACTIVE', + delivery_status: 'NOT_SENT', + sent_at: null, + expires_at: '2026-08-07T00:00:00Z', + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + worker_link_id: 'L-1', + link_status: 'ACTIVE', + delivery_status: 'SENT', + sent_at: '2026-08-05T00:00:00Z', + expires_at: '2026-08-07T00:00:00Z', + }), + ) + + await fetchTaskWorkerLinkDelivery('T/1') + await markWorkerLinkSent('L/1') + + const calls = vi.mocked(fetch).mock.calls + expect(String(calls[0][0])).toContain('/tasks/T%2F1/worker-link') + expect(calls[0][1]?.method).toBeUndefined() + expect(String(calls[1][0])).toContain('/worker-links/L%2F1/sent') + expect(calls[1][1]?.method).toBe('POST') + }) + it('views, uploads and submits through the public token endpoints', async () => { vi.mocked(fetch).mockImplementation(() => Promise.resolve(jsonResponse({ upload_id: 'U-1' }, 201))) @@ -40,6 +75,23 @@ describe('worker link APIs', () => { expect(String(calls[2][0])).toContain('/responses') }) + it('lists and marks HR worker responses as reviewed through authenticated endpoints', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + jsonResponse({ items: [], page: 1, size: 10, total_elements: 0, total_pages: 0 }), + ) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + + await fetchTaskWorkerResponses('T/1', 1, 10) + await markTaskWorkerResponsesRead('T/1') + + const calls = vi.mocked(fetch).mock.calls + expect(String(calls[0][0])).toContain('/tasks/T%2F1/worker-responses?page=1&size=10') + expect(calls[0][1]?.method).toBeUndefined() + expect(String(calls[1][0])).toContain('/tasks/T%2F1/worker-responses/read') + expect(calls[1][1]?.method).toBe('POST') + }) + it('turns the current backend raw-token response into a frontend route', () => { expect(resolveWorkerPortalUrl('raw/token', 'https://fowoco.kr')).toBe( 'https://fowoco.kr/worker-portal/raw%2Ftoken', diff --git a/src/api/workerLinks.ts b/src/api/workerLinks.ts index b4e5fde..e041452 100644 --- a/src/api/workerLinks.ts +++ b/src/api/workerLinks.ts @@ -13,7 +13,22 @@ export interface WorkerLinkIssueBody { } export interface WorkerLinkIssueResponse { - worker_url: string + worker_link_id: string + worker_url: string | null + expires_at: string + delivery_status: WorkerLinkDeliveryStatus + sent_at: string | null + already_issued: boolean +} + +export type WorkerLinkStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' +export type WorkerLinkDeliveryStatus = 'NOT_SENT' | 'SENT' + +export interface WorkerLinkDeliveryResponse { + worker_link_id: string + link_status: WorkerLinkStatus + delivery_status: WorkerLinkDeliveryStatus + sent_at: string | null expires_at: string } @@ -42,6 +57,26 @@ export interface WorkerResponseSubmitResponse { received_at: string } +export type WorkerConversationStatus = 'WAITING_WORKER' | 'NEEDS_FOLLOWUP' | 'REOPENED' + +export interface WorkerResponseItemResponse { + response_id: string + response_type: WorkerResponseType + message: string | null + upload_ids: string[] + conversation_status: WorkerConversationStatus + unread: boolean + received_at: string +} + +export interface WorkerResponsePageResponse { + items: WorkerResponseItemResponse[] + page: number + size: number + total_elements: number + total_pages: number +} + export function issueWorkerLink( taskId: string, body: WorkerLinkIssueBody, @@ -54,6 +89,21 @@ export function issueWorkerLink( }) } +export function fetchTaskWorkerLinkDelivery( + taskId: string, +): Promise { + return apiFetch( + `/tasks/${encodeURIComponent(taskId)}/worker-link`, + ) +} + +export function markWorkerLinkSent(workerLinkId: string): Promise { + return apiFetch( + `/worker-links/${encodeURIComponent(workerLinkId)}/sent`, + { method: 'POST' }, + ) +} + export function fetchWorkerLink(token: string): Promise { return apiFetch(`/public/worker-links/${encodeURIComponent(token)}`, { skipAuthRetry: true, @@ -96,6 +146,24 @@ export function submitWorkerResponse( ) } +export function fetchTaskWorkerResponses( + taskId: string, + page = 0, + size = 20, +): Promise { + const query = new URLSearchParams({ page: String(page), size: String(size) }) + return apiFetch( + `/tasks/${encodeURIComponent(taskId)}/worker-responses?${query.toString()}`, + ) +} + +export function markTaskWorkerResponsesRead(taskId: string): Promise { + return apiFetch( + `/tasks/${encodeURIComponent(taskId)}/worker-responses/read`, + { method: 'POST' }, + ) +} + export function resolveWorkerPortalUrl(workerUrlOrToken: string, origin: string): string { if (/^https?:\/\//i.test(workerUrlOrToken)) return workerUrlOrToken if (workerUrlOrToken.startsWith('/')) return new URL(workerUrlOrToken, origin).toString() diff --git a/src/pages/CaseDetailPage/CaseDetailPage.module.css b/src/pages/CaseDetailPage/CaseDetailPage.module.css index 2ae76e7..881473f 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.module.css +++ b/src/pages/CaseDetailPage/CaseDetailPage.module.css @@ -315,6 +315,20 @@ color: var(--fowoco-green-600); } +.readinessError { + margin-top: 8px; + padding: var(--fowoco-spacing-12) var(--fowoco-spacing-16); + border-radius: var(--fowoco-radius-6); + background: var(--fowoco-red-50); +} + +.readinessError p { + margin: 0 0 6px; + color: var(--status-critical); + font-size: 12px; + line-height: 18px; +} + .tabPanel { margin-top: 20px; } @@ -406,6 +420,51 @@ color: var(--text-secondary); } +.responseOverview { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-bottom: 16px; + padding: var(--fowoco-spacing-20) var(--fowoco-spacing-24); + background: var(--surface-default); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-10); + box-shadow: var(--shadow-sm); +} + +.responseOverviewCopy { + min-width: 0; +} + +.responseEyebrow { + margin: 0 0 6px; + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; +} + +.responseTitleRow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.responseTitle { + margin: 0; + color: var(--text-primary); + font-size: 18px; + line-height: 28px; +} + +.responseDescription { + margin: 6px 0 0; + color: var(--text-secondary); + font-size: 13px; + line-height: 20px; +} + .commList { display: flex; flex-direction: column; @@ -413,39 +472,68 @@ } .commRow { - display: flex; - align-items: flex-start; - gap: 16px; + display: grid; + grid-template-columns: 132px minmax(0, 1fr); + gap: 20px; padding: var(--fowoco-spacing-16) var(--fowoco-spacing-24); background: var(--surface-default); border: 1px solid var(--border-default); border-radius: var(--fowoco-radius-8); } +.commRowUnread { + border-color: var(--fowoco-amber-200); + background: var(--fowoco-amber-50); +} + +.commMeta { + display: flex; + align-items: flex-start; + flex-direction: column; + gap: 8px; +} + .commTime { - flex-shrink: 0; - width: 78px; font-size: 12px; color: var(--text-secondary); } +.commContent { + min-width: 0; +} + +.commHeading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + .commActor { - flex-shrink: 0; - width: 70px; font-size: 13px; - font-weight: 500; + font-weight: 700; color: var(--text-primary); } +.commType { + font-size: 12px; + color: var(--brand-primary); +} + .commMessage { - flex: 1; - min-width: 0; - margin: 0; + margin: 8px 0 0; font-size: 13px; + line-height: 20px; color: var(--text-primary); overflow-wrap: break-word; } +.commFiles { + margin: 8px 0 0; + font-size: 12px; + color: var(--text-secondary); +} + .timeline { display: flex; flex-direction: column; @@ -552,6 +640,24 @@ flex-direction: column; gap: 12px; } + + .responseOverview { + align-items: stretch; + flex-direction: column; + gap: 16px; + padding: 16px; + } + + .commRow { + grid-template-columns: 1fr; + gap: 12px; + padding: 16px; + } + + .commMeta { + align-items: center; + flex-direction: row; + } } .contextSection + .contextSection { @@ -574,3 +680,10 @@ font-size: 13px; color: var(--text-primary); } + +.contextEmpty { + margin: 0; + color: var(--text-secondary); + font-size: 13px; + line-height: 20px; +} diff --git a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx index db9cd8f..abc7ea1 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx @@ -3,16 +3,22 @@ import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AuditEventResponse } from '../../api/audit' +import type { CaseProjectionResponse } from '../../api/cases' import type { DocumentItemResponse, DocumentPageResponse, DocumentReadinessResponse, } from '../../api/documents' import type { TaskDetailResponse } from '../../api/tasks' +import type { + WorkerLinkDeliveryResponse, + WorkerResponseItemResponse, +} from '../../api/workerLinks' import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport' +import { useAuthStore } from '../../store/authStore' import { useToastStore } from '../../store/toastStore' import { CaseDetailPage } from './CaseDetailPage' -import { CASE_COMMUNICATION, CASE_TABS, CONTEXT_DRAWER } from './caseDetailData' +import { CASE_TABS } from './caseDetailData' function jsonResponse(body: unknown, init: ResponseInit = {}) { return new Response(JSON.stringify(body), { @@ -120,17 +126,101 @@ function documentsResponse(items: DocumentItemResponse[] = []): DocumentPageResp return { items, page: 0, size: 100, total_elements: items.length } } +function caseProjectionResponse( + overrides: Partial = {}, +): CaseProjectionResponse { + return { + case_id: 'C-1', + worker_id: 'W-1', + worker_display_name: '응웬반A', + title: '체류기간 연장', + display_status: 'REVIEW_REQUIRED', + has_unread_response: false, + priority: 'HIGH', + progress: { completed_steps: 1, total_steps: 2, percentage: 50 }, + due_date: '2026-08-01', + current_task: { + task_id: 'T-1', + task_type: 'STAY_PERIOD_EXTENSION', + title: '응웬반A 체류연장 준비', + status: 'READY_FOR_REVIEW', + due_date: '2026-08-01', + }, + updated_at: '2026-07-20T00:00:00Z', + lifecycle_status: 'ACTIVE', + readiness: { + completed_checklist_items: 1, + total_checklist_items: 2, + verified_documents: 2, + total_documents: 3, + pending_approvals: 1, + approved_approvals: 0, + worker_responses: 1, + evidence_items: 0, + }, + tasks: [ + { + task_id: 'T-1', + task_type: 'STAY_PERIOD_EXTENSION', + title: '응웬반A 체류연장 준비', + status: 'READY_FOR_REVIEW', + due_date: '2026-08-01', + }, + ], + workflow_catalog_version: '1', + workflow_snapshot: {}, + ...overrides, + } +} + function mockTaskAndActivities( taskOverrides: Partial = {}, activities: AuditEventResponse[] = [], - readinessOverrides: Partial = {}, + readinessOverrides: Partial | Response = {}, documents: DocumentItemResponse[] = [], + workerResponses: WorkerResponseItemResponse[] = [], + workerLinkDelivery: WorkerLinkDeliveryResponse | null = null, + caseProjection: CaseProjectionResponse | null = null, ) { - vi.mocked(fetch).mockImplementation((input) => { + let responsesReviewed = false + let currentWorkerLinkDelivery = workerLinkDelivery + vi.mocked(fetch).mockImplementation((input, init) => { const url = String(input) if (url.includes('/activities')) return Promise.resolve(jsonResponse(activities)) - if (url.includes('/document-readiness')) - return Promise.resolve(jsonResponse(readinessResponse(readinessOverrides))) + if (url.includes('/cases/') && url.endsWith('/projection')) { + return Promise.resolve( + caseProjection + ? jsonResponse(caseProjection) + : errorResponse(404, 'CASE_NOT_FOUND', 'Case 없음'), + ) + } + if (url.endsWith('/worker-responses/read')) { + responsesReviewed = true + return Promise.resolve(new Response(null, { status: 204 })) + } + if (url.includes('/worker-responses?')) { + const items = workerResponses.map((response) => + responsesReviewed + ? { ...response, unread: false, conversation_status: 'REOPENED' as const } + : response, + ) + return Promise.resolve( + jsonResponse({ + items, + page: 0, + size: 100, + total_elements: items.length, + total_pages: items.length ? 1 : 0, + }), + ) + } + if (url.includes('/document-readiness')) { + return Promise.resolve( + readinessOverrides instanceof Response + ? readinessOverrides + : jsonResponse(readinessResponse(readinessOverrides)), + ) + } if (url.includes('/document-request-draft')) { return Promise.resolve( jsonResponse({ draft_id: 'draft-1', version: 1, review_status: 'PENDING' }), @@ -156,10 +246,28 @@ function mockTaskAndActivities( jsonResponse({ task_id: 'T-1', task_status: 'DRAFT', task_version: 2 }), ) } + if (url.endsWith('/external-submissions')) { + return Promise.resolve( + jsonResponse( + { + resource_id: 'S-1', + task_id: 'T-1', + task_status: 'WAITING_EXTERNAL', + task_version: 2, + }, + { status: 201 }, + ), + ) + } if (url.endsWith('/evidence')) { return Promise.resolve( jsonResponse( - { resource_id: 'E-1', task_id: 'T-1', task_status: 'APPROVED', task_version: 1 }, + { + resource_id: 'E-1', + task_id: 'T-1', + task_status: 'WAITING_EXTERNAL', + task_version: 2, + }, { status: 201 }, ), ) @@ -175,13 +283,44 @@ function mockTaskAndActivities( ) } if (url.endsWith('/worker-link')) { + if (init?.method !== 'POST') { + return Promise.resolve( + currentWorkerLinkDelivery + ? jsonResponse(currentWorkerLinkDelivery) + : errorResponse(404, 'WORKER_LINK_RESOURCE_NOT_FOUND', '근로자 링크 없음'), + ) + } + currentWorkerLinkDelivery = { + worker_link_id: 'L-1', + link_status: 'ACTIVE', + delivery_status: 'NOT_SENT', + sent_at: null, + expires_at: '2026-08-07T00:00:00Z', + } return Promise.resolve( jsonResponse( - { worker_url: 'worker-token-1', expires_at: '2026-08-07T00:00:00Z' }, + { + worker_link_id: 'L-1', + worker_url: 'worker-token-1', + expires_at: '2026-08-07T00:00:00Z', + delivery_status: 'NOT_SENT', + sent_at: null, + already_issued: false, + }, { status: 201 }, ), ) } + if (url.endsWith('/worker-links/L-1/sent')) { + currentWorkerLinkDelivery = { + worker_link_id: 'L-1', + link_status: 'ACTIVE', + delivery_status: 'SENT', + sent_at: '2026-08-05T01:00:00Z', + expires_at: '2026-08-07T00:00:00Z', + } + return Promise.resolve(jsonResponse(currentWorkerLinkDelivery)) + } return Promise.resolve(jsonResponse(task(taskOverrides))) }) } @@ -193,11 +332,20 @@ function mockTaskError(status: number, code: string, message: string) { if (url.includes('/document-readiness')) return Promise.resolve(jsonResponse(readinessResponse())) if (url.includes('/documents?')) return Promise.resolve(jsonResponse(documentsResponse())) + if (url.includes('/worker-responses?')) { + return Promise.resolve( + jsonResponse({ items: [], page: 0, size: 100, total_elements: 0, total_pages: 0 }), + ) + } + if (url.endsWith('/worker-link')) { + return Promise.resolve(errorResponse(404, 'WORKER_LINK_RESOURCE_NOT_FOUND', '근로자 링크 없음')) + } return Promise.resolve(errorResponse(status, code, message)) }) } beforeEach(() => { + useAuthStore.setState({ user: null, status: 'ready' }) useToastStore.setState({ toasts: [] }) vi.stubGlobal('fetch', vi.fn()) }) @@ -206,9 +354,9 @@ afterEach(() => { vi.unstubAllGlobals() }) -function renderPage() { +function renderPage(initialEntry = '/tasks/T-1') { render( - + { expect(await screen.findByText('서류 요청 초안을 저장했습니다.')).toBeInTheDocument() }) - it('switches to the communication tab and shows message content', async () => { + it('shows real unread worker responses and marks them as reviewed', async () => { + const user = userEvent.setup() + mockTaskAndActivities({}, [], {}, [], [ + { + response_id: 'response-1', + response_type: 'DOCUMENT_SUBMITTED', + message: '여권 사본을 제출했습니다.', + upload_ids: ['upload-1'], + conversation_status: 'NEEDS_FOLLOWUP', + unread: true, + received_at: '2026-07-20T01:00:00Z', + }, + ]) + renderPage() + await screen.findByText('응웬반A 체류연장 준비') + + await user.click(screen.getByRole('tab', { name: CASE_TABS[3] })) + + expect(screen.getByText('승인대기')).toBeInTheDocument() + expect(screen.getByText('여권 사본을 제출했습니다.')).toBeInTheDocument() + expect(screen.getByText('제출 파일 1개')).toBeInTheDocument() + expect(screen.queryByText('여권 사본 요청문 초안을 준비했습니다.')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '응답 확인 완료' })) + + expect(await screen.findByText('근로자 응답을 확인 처리했습니다.')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('확인됨')).toBeInTheDocument() + }) + expect( + vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/worker-responses/read')), + ).toBe(true) + }) + + it('shows a worker-response empty state instead of static demo messages', async () => { const user = userEvent.setup() mockTaskAndActivities() renderPage() @@ -352,7 +534,62 @@ describe('CaseDetailPage', () => { await user.click(screen.getByRole('tab', { name: CASE_TABS[3] })) - expect(screen.getByText(CASE_COMMUNICATION[0].message)).toBeInTheDocument() + expect(screen.getByText('서류대기')).toBeInTheDocument() + expect(screen.getByText('도착한 근로자 응답이 없습니다')).toBeInTheDocument() + }) + + it('uses the persisted link delivery status and records delivery after confirmation', async () => { + const user = userEvent.setup() + mockTaskAndActivities({}, [], {}, [], [], { + worker_link_id: 'L-1', + link_status: 'ACTIVE', + delivery_status: 'NOT_SENT', + sent_at: null, + expires_at: '2026-08-07T00:00:00Z', + }) + renderPage() + await screen.findByText('응웬반A 체류연장 준비') + + await user.click(screen.getByRole('tab', { name: CASE_TABS[3] })) + + expect(screen.getByText('서류대기')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: '전달 완료 기록' })) + expect(screen.getByRole('dialog', { name: '링크 전달 완료 기록' })).toBeInTheDocument() + + await user.click(screen.getByLabelText('근로자에게 링크를 직접 전달했습니다.')) + await user.click(screen.getByRole('button', { name: '전달 완료로 기록' })) + + expect(await screen.findByText('근로자 링크 전달 완료를 기록했습니다.')).toBeInTheDocument() + await waitFor(() => expect(screen.getByText('요청전송')).toBeInTheDocument()) + expect( + vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/worker-links/L-1/sent')), + ).toBe(true) + }) + + it('does not offer the response review action to a viewer', async () => { + const user = userEvent.setup() + useAuthStore.setState({ + user: { name: 'viewer', workplace: 'FOWOCO', role: 'VIEWER' }, + status: 'ready', + }) + mockTaskAndActivities({}, [], {}, [], [ + { + response_id: 'response-1', + response_type: 'QUESTION', + message: '어떤 서류가 필요한가요?', + upload_ids: [], + conversation_status: 'NEEDS_FOLLOWUP', + unread: true, + received_at: '2026-07-20T01:00:00Z', + }, + ]) + renderPage() + await screen.findByText('응웬반A 체류연장 준비') + + await user.click(screen.getByRole('tab', { name: CASE_TABS[3] })) + + expect(screen.getByText('미확인')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '응답 확인 완료' })).not.toBeInTheDocument() }) it('switches to the activity tab and shows real activity content', async () => { @@ -374,6 +611,28 @@ describe('CaseDetailPage', () => { expect(await screen.findByText(/완료 처리 불가 · 승인 · 필수 체크리스트/)).toBeInTheDocument() }) + it('blocks completion and offers retry when document readiness cannot be verified', async () => { + const user = userEvent.setup() + mockTaskAndActivities( + {}, + [], + errorResponse(503, 'SERVICE_UNAVAILABLE', '서류 상태 확인 지연'), + ) + renderPage() + + expect(await screen.findByText('조회 실패')).toBeInTheDocument() + expect(screen.getByText('서류 상태 확인 지연')).toBeInTheDocument() + expect(screen.getByText(/완료 처리 불가.*서류 상태 확인/)).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '다시 조회 →' })) + + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => + String(url).includes('/document-readiness'), + ), + ).toHaveLength(2) + }) + it('requests approval through the API and refetches the Task', async () => { const user = userEvent.setup() mockTaskAndActivities({ @@ -509,9 +768,17 @@ describe('CaseDetailPage', () => { expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) - it('opens the context drawer with every section and closes on Escape', async () => { + it('opens the context drawer with the real Case projection and closes on Escape', async () => { const user = userEvent.setup() - mockTaskAndActivities({}, [activity()]) + mockTaskAndActivities( + { case_id: 'C-1' }, + [activity()], + {}, + [], + [], + null, + caseProjectionResponse(), + ) renderPage() await screen.findByText('응웬반A 체류연장 준비') @@ -519,22 +786,44 @@ describe('CaseDetailPage', () => { await user.click(screen.getByRole('button', { name: '펼쳐 보기 →' })) - expect(screen.getByRole('dialog')).toBeInTheDocument() - expect(screen.getByText('Agent가 확인한 내용')).toBeInTheDocument() - expect(screen.getByText(CONTEXT_DRAWER.agentConfirmed[0])).toBeInTheDocument() - expect(screen.getByText('부족한 정보')).toBeInTheDocument() - expect(screen.getByText(CONTEXT_DRAWER.missingInfo[0])).toBeInTheDocument() - expect(screen.getByText('공식 출처')).toBeInTheDocument() - expect(screen.getByText(CONTEXT_DRAWER.officialSources[0].label)).toBeInTheDocument() - expect(screen.getByText('최근 활동')).toBeInTheDocument() - expect(screen.getByText('Agent가 체류연장 요청문 초안을 작성함')).toBeInTheDocument() - expect(screen.getByText('HR이 할 일')).toBeInTheDocument() - expect(screen.getByText(CONTEXT_DRAWER.hrTodo[0])).toBeInTheDocument() + const drawer = screen.getByRole('dialog') + expect(drawer).toBeInTheDocument() + expect(within(drawer).getByText('Case 현황')).toBeInTheDocument() + expect(within(drawer).getByText('준비 현황')).toBeInTheDocument() + expect(within(drawer).getByText('검증 서류')).toBeInTheDocument() + expect(within(drawer).getByText('2 / 3')).toBeInTheDocument() + expect(within(drawer).getByText('연결된 업무')).toBeInTheDocument() + expect(within(drawer).getByText('체류기간 연장 · 검토 필요')).toBeInTheDocument() + expect(within(drawer).getByText('최근 활동')).toBeInTheDocument() + expect(within(drawer).getByText('Agent가 체류연장 요청문 초안을 작성함')).toBeInTheDocument() await user.keyboard('{Escape}') expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) + it('opens the real Case context immediately from a context deep link', async () => { + mockTaskAndActivities( + { case_id: 'C-1' }, + [activity()], + {}, + [], + [], + null, + caseProjectionResponse(), + ) + + renderPage('/tasks/T-1?context=open') + + const drawer = await screen.findByRole('dialog', { name: '관련 Context' }) + expect(await within(drawer).findByText('Case 현황')).toBeInTheDocument() + expect(within(drawer).getByText('응웬반A')).toBeInTheDocument() + expect( + vi.mocked(fetch).mock.calls.some(([url]) => + String(url).endsWith('/cases/C-1/projection'), + ), + ).toBe(true) + }) + it('records evidence and completes through the API when the server Task is approved', async () => { const user = userEvent.setup() mockTaskAndActivities({ @@ -558,6 +847,7 @@ describe('CaseDetailPage', () => { await user.click(await screen.findByRole('button', { name: '완료 처리 시작 →' })) expect(screen.getByRole('dialog', { name: '외부기관 업무 완료' })).toBeInTheDocument() + await user.type(screen.getByLabelText('제출 기관'), '수원출입국·외국인청') await user.click(screen.getByRole('button', { name: '접수번호' })) await user.type(screen.getByPlaceholderText('접수번호를 입력하세요'), 'HI-2026-0718-032') await user.click(screen.getByLabelText('실제 제출은 담당자가 직접 수행했습니다.')) @@ -568,6 +858,9 @@ describe('CaseDetailPage', () => { ) expect(screen.getByText('업무를 완료했습니다.')).toBeInTheDocument() + expect( + vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/external-submissions')), + ).toBe(true) expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/evidence'))).toBe( true, ) @@ -576,6 +869,47 @@ describe('CaseDetailPage', () => { ) }) + it('does not duplicate the external submission when retrying from WAITING_EXTERNAL', async () => { + const user = userEvent.setup() + mockTaskAndActivities({ + status: 'WAITING_EXTERNAL', + checklist_items: [ + { + checklist_item_id: 'chk-1', + item_code: 'passport', + label: '여권 사본 확인', + required: true, + completed: true, + completed_by: null, + completed_at: null, + version: 1, + }, + ], + }) + renderPage() + await screen.findByText('응웬반A 체류연장 준비') + + await user.click(await screen.findByRole('button', { name: '완료 처리 시작 →' })) + expect(screen.queryByLabelText('제출 기관')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '접수번호' })) + await user.type(screen.getByPlaceholderText('접수번호를 입력하세요'), 'HI-2026-0718-032') + await user.click(screen.getByLabelText('실제 제출은 담당자가 직접 수행했습니다.')) + await user.click( + within(screen.getByRole('dialog', { name: '외부기관 업무 완료' })).getByRole('button', { + name: '완료 처리', + }), + ) + + expect(await screen.findByText('업무를 완료했습니다.')).toBeInTheDocument() + expect( + vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/external-submissions')), + ).toBe(false) + expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/evidence'))).toBe( + true, + ) + }) + it('issues the security link through the API and shows the real URL', async () => { const user = userEvent.setup() mockTaskAndActivities({ status: 'APPROVED' }) @@ -595,5 +929,6 @@ describe('CaseDetailPage', () => { expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/worker-link'))).toBe( true, ) + expect(screen.getByRole('button', { name: '전달 완료 기록' })).toBeEnabled() }) }) diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx index a564c83..27019df 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx @@ -1,15 +1,17 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { useParams } from 'react-router-dom' +import { useParams, useSearchParams } from 'react-router-dom' import { approveTask, buildTaskApprovalSnapshot, completeTask, + recordExternalSubmission, recordTaskEvidence, rejectTask, requestTaskApproval, type EvidenceType, } from '../../api/approvals' import { fetchTaskActivities } from '../../api/audit' +import { fetchCaseProjection, type CaseProjectionResponse } from '../../api/cases' import { fetchDocumentReadiness, fetchDocuments, @@ -17,7 +19,16 @@ import { } from '../../api/documents' import { ApiError, getErrorMessage } from '../../api/errors' import { cancelTask, fetchTaskById, updateChecklistItem } from '../../api/tasks' -import { issueWorkerLink, resolveWorkerPortalUrl } from '../../api/workerLinks' +import { + fetchTaskWorkerLinkDelivery, + fetchTaskWorkerResponses, + issueWorkerLink, + markTaskWorkerResponsesRead, + markWorkerLinkSent, + resolveWorkerPortalUrl, + type WorkerLinkDeliveryResponse, + type WorkerResponseType, +} from '../../api/workerLinks' import { AgentSourceLabel } from '../../components/ui/AgentSourceLabel/AgentSourceLabel' import { AgentSummary } from '../../components/ui/AgentSummary/AgentSummary' import { Button } from '../../components/ui/Button/Button' @@ -27,24 +38,39 @@ import { EmptyState } from '../../components/ui/EmptyState/EmptyState' import { StatusLabel, type StatusTone } from '../../components/ui/StatusLabel/StatusLabel' import { Tabs } from '../../components/ui/Tabs/Tabs' import { useApiQuery } from '../../hooks/useApiQuery' +import { useAuthStore } from '../../store/authStore' import { useToastStore } from '../../store/toastStore' import { ACTOR_TYPE_TO_AGENT_SOURCE, getAuditActionLabel } from '../../utils/auditLabels' import { formatEventTime } from '../../utils/datetime' import { TASK_SOURCE_LABEL, TASK_STATUS_LABEL, TASK_STATUS_TONE } from '../../utils/taskStatus' import { getDocumentViewModel } from '../../view-models/documentViewModel' import { getOperationalDateViewModel } from '../../view-models/dateViewModel' +import { + getWorkerRequestStateViewModel, + type WorkerRequestState, +} from '../../view-models/workerRequestStateViewModel' import styles from './CaseDetailPage.module.css' -import { CASE_COMMUNICATION, CASE_TABS, CONTEXT_DRAWER } from './caseDetailData' +import { CASE_TABS } from './caseDetailData' +import { + getCaseDisplayStatusPresentation, + getTaskStatusPresentation, + getWorkflowLabel, +} from '../WorkListPage/workInboxPresentation' import { ApprovalDecisionModal } from './overlays/ApprovalDecisionModal' import { ApprovalRequestModal } from './overlays/ApprovalRequestModal' -import { ExternalCompletionModal } from './overlays/ExternalCompletionModal' +import { + ExternalCompletionModal, + type ExternalCompletionSubmission, +} from './overlays/ExternalCompletionModal' +import { LinkDeliveryConfirmModal } from './overlays/LinkDeliveryConfirmModal' import { LinkReissueModal, type ReissueSubmission } from './overlays/LinkReissueModal' import { LinkReissuedModal } from './overlays/LinkReissuedModal' import { RejectionReasonModal } from './overlays/RejectionReasonModal' type ApprovalOverlay = 'none' | 'request' | 'decision' | 'rejection' type CompletionOverlay = 'none' | 'external' -type LinkOverlay = 'none' | 'reissue' | 'reissued' +type LinkOverlay = 'none' | 'reissue' | 'reissued' | 'delivery' +type DeliveryConfirmReturn = 'none' | 'reissued' const CASE_TAB_ITEMS = CASE_TABS.map((label) => ({ id: label, label })) @@ -65,22 +91,74 @@ const EVIDENCE_TYPE_BY_LABEL: Record = { '화면 캡처': 'OFFICIAL_RESULT', } +const WORKER_RESPONSE_TYPE_LABEL: Record = { + ACKNOWLEDGED: '확인했습니다', + QUESTION: '질문', + NOT_UNDERSTOOD: '이해가 어려워요', + DOCUMENT_SUBMITTED: '서류 제출', + DIFFICULT: '진행이 어려워요', +} + +const WORKER_REQUEST_STATE_TONE: Record = { + DOCUMENT_WAITING: 'neutral', + REQUEST_SENT: 'info', + APPROVAL_WAITING: 'warning', + COMPLETED: 'success', +} + +const CASE_LIFECYCLE_LABEL: Record = { + ACTIVE: '진행 중', + COMPLETED: '완료', + CANCELLED: '취소', +} + +async function fetchTaskWorkerLinkDeliveryOrNull( + taskId: string, +): Promise { + try { + return await fetchTaskWorkerLinkDelivery(taskId) + } catch (error) { + if ( + error instanceof ApiError && + (error.status === 404 || error.code === 'WORKER_LINK_RESOURCE_NOT_FOUND') + ) { + return null + } + throw error + } +} + export function CaseDetailPage() { const { taskId } = useParams() + const [searchParams, setSearchParams] = useSearchParams() + const contextRequested = searchParams.get('context') === 'open' const [activeTab, setActiveTab] = useState(CASE_TABS[0]) const [moreMenuOpen, setMoreMenuOpen] = useState(false) - const [contextDrawerOpen, setContextDrawerOpen] = useState(false) + const [contextDrawerOpen, setContextDrawerOpen] = useState(contextRequested) const [approvalOverlay, setApprovalOverlay] = useState('none') const [completionOverlay, setCompletionOverlay] = useState('none') const [actionPending, setActionPending] = useState(false) const [togglingItemId, setTogglingItemId] = useState(null) const [linkOverlay, setLinkOverlay] = useState('none') + const [deliveryConfirmReturn, setDeliveryConfirmReturn] = + useState('none') const [lastReissue, setLastReissue] = useState(null) const [issuedWorkerUrl, setIssuedWorkerUrl] = useState(null) const [issuedExpiresAt, setIssuedExpiresAt] = useState(null) + const [localWorkerLinkDelivery, setLocalWorkerLinkDelivery] = useState<{ + taskId: string + data: WorkerLinkDeliveryResponse + } | null>(null) + const [markingLinkSent, setMarkingLinkSent] = useState(false) + const [markingResponsesRead, setMarkingResponsesRead] = useState(false) const moreMenuRef = useRef(null) + const userRole = useAuthStore((state) => state.user?.role) const showToast = useToastStore((state) => state.showToast) + useEffect(() => { + if (contextRequested) setContextDrawerOpen(true) + }, [contextRequested]) + const taskFetcher = useCallback(() => fetchTaskById(taskId ?? ''), [taskId]) const { status: taskStatus, @@ -90,11 +168,16 @@ export function CaseDetailPage() { } = useApiQuery(taskFetcher) const activitiesFetcher = useCallback(() => fetchTaskActivities(taskId ?? ''), [taskId]) - const { data: activities } = useApiQuery(activitiesFetcher) + const { data: activities, refetch: refetchActivities } = useApiQuery(activitiesFetcher) const activityRows = activities ?? [] const readinessFetcher = useCallback(() => fetchDocumentReadiness(taskId ?? ''), [taskId]) - const { data: readiness } = useApiQuery(readinessFetcher) + const { + status: readinessStatus, + data: readiness, + error: readinessError, + refetch: refetchReadiness, + } = useApiQuery(readinessFetcher) const workerId = task?.worker_id const documentsFetcher = useCallback(() => { @@ -112,6 +195,62 @@ export function CaseDetailPage() { ) const documents = documentsPage?.items ?? [] + const workerResponsesFetcher = useCallback( + () => fetchTaskWorkerResponses(taskId ?? '', 0, 100), + [taskId], + ) + const { + status: workerResponsesStatus, + data: workerResponsesPage, + error: workerResponsesError, + refetch: refetchWorkerResponses, + } = useApiQuery( + workerResponsesFetcher, + useCallback((page: { items: unknown[] }) => page.items.length === 0, []), + ) + const workerResponses = workerResponsesPage?.items ?? [] + + const caseId = task?.case_id + const caseProjectionFetcher = useCallback(() => { + if (!caseId || !contextDrawerOpen) return Promise.resolve(null) + return fetchCaseProjection(caseId) + }, [caseId, contextDrawerOpen]) + const { + status: caseProjectionStatus, + data: caseProjection, + error: caseProjectionError, + refetch: refetchCaseProjection, + } = useApiQuery( + caseProjectionFetcher, + useCallback((projection: CaseProjectionResponse | null) => projection === null, []), + ) + + const workerLinkDeliveryFetcher = useCallback(() => { + if (!taskId || userRole === 'VIEWER') return Promise.resolve(null) + return fetchTaskWorkerLinkDeliveryOrNull(taskId) + }, [taskId, userRole]) + const { + status: workerLinkDeliveryStatus, + data: workerLinkDelivery, + error: workerLinkDeliveryError, + refetch: refetchWorkerLinkDelivery, + } = useApiQuery( + workerLinkDeliveryFetcher, + useCallback((delivery: WorkerLinkDeliveryResponse | null) => delivery === null, []), + ) + + useEffect(() => { + if (!localWorkerLinkDelivery || !workerLinkDelivery) return + if ( + localWorkerLinkDelivery.taskId === taskId && + workerLinkDelivery.worker_link_id === localWorkerLinkDelivery.data.worker_link_id && + (workerLinkDelivery.delivery_status === localWorkerLinkDelivery.data.delivery_status || + workerLinkDelivery.delivery_status === 'SENT') + ) { + setLocalWorkerLinkDelivery(null) + } + }, [localWorkerLinkDelivery, taskId, workerLinkDelivery]) + useEffect(() => { if (!moreMenuOpen) return @@ -188,22 +327,50 @@ export function CaseDetailPage() { setCompletionOverlay('external') } - async function handleCompleteExternal(evidenceType: string, evidenceValue: string, memo: string) { + async function handleCompleteExternal(submission: ExternalCompletionSubmission) { if (!task || actionPending) return - const normalizedEvidenceType = EVIDENCE_TYPE_BY_LABEL[evidenceType] + const normalizedEvidenceType = EVIDENCE_TYPE_BY_LABEL[submission.evidenceType] if (!normalizedEvidenceType) return + const evidenceValue = submission.evidenceValue.trim() + const memo = submission.memo.trim() + let externalSubmissionRecorded = task.status === 'WAITING_EXTERNAL' setActionPending(true) try { + if (!externalSubmissionRecorded) { + await recordExternalSubmission(task.task_id, { + expected_version: task.version, + destination: submission.destination, + safe_reference: evidenceValue, + }) + externalSubmissionRecorded = true + } + const evidence = await recordTaskEvidence(task.task_id, { evidence_type: normalizedEvidenceType, - note: [evidenceValue.trim(), memo.trim()].filter(Boolean).join(' · '), + file_reference: + normalizedEvidenceType === 'DOCUMENT' || normalizedEvidenceType === 'OFFICIAL_RESULT' + ? evidenceValue + : undefined, + note: + normalizedEvidenceType === 'RECEIPT' + ? [evidenceValue, memo].filter(Boolean).join(' · ') + : memo || undefined, }) await completeTask(task.task_id, evidence.task_version) setCompletionOverlay('none') refetchTask() + refetchActivities() showToast('업무를 완료했습니다.') } catch (error) { - showToast(error instanceof ApiError ? getErrorMessage(error) : '업무를 완료하지 못했습니다.') + refetchTask() + refetchActivities() + const detail = + error instanceof ApiError ? getErrorMessage(error) : '업무를 완료하지 못했습니다.' + showToast( + externalSubmissionRecorded + ? `외부 제출 기록은 저장됐습니다. ${detail}` + : detail, + ) } finally { setActionPending(false) } @@ -288,10 +455,29 @@ export function CaseDetailPage() { crypto.randomUUID(), ) setLastReissue(submission) - setIssuedWorkerUrl(resolveWorkerPortalUrl(issued.worker_url, window.location.origin)) + setIssuedWorkerUrl( + issued.worker_url + ? resolveWorkerPortalUrl(issued.worker_url, window.location.origin) + : null, + ) setIssuedExpiresAt(issued.expires_at) + setLocalWorkerLinkDelivery({ + taskId: task.task_id, + data: { + worker_link_id: issued.worker_link_id, + link_status: 'ACTIVE', + delivery_status: issued.delivery_status, + sent_at: issued.sent_at, + expires_at: issued.expires_at, + }, + }) + refetchWorkerLinkDelivery() setLinkOverlay('reissued') - showToast('보안 링크를 발급했습니다. 아직 자동 전송되지는 않았습니다.') + showToast( + issued.worker_url + ? '보안 링크를 발급했습니다. 아직 자동 전송되지는 않았습니다.' + : '링크는 이미 발급됐지만 보안상 원문을 다시 표시할 수 없습니다.', + ) } catch (error) { showToast( error instanceof ApiError ? getErrorMessage(error) : '보안 링크를 발급하지 못했습니다.', @@ -301,6 +487,58 @@ export function CaseDetailPage() { } } + function handleOpenDeliveryConfirm(returnTo: DeliveryConfirmReturn = 'none') { + setDeliveryConfirmReturn(returnTo) + setLinkOverlay('delivery') + } + + async function handleMarkLinkSent() { + if (!task || markingLinkSent) return + const currentDelivery = + localWorkerLinkDelivery?.taskId === task.task_id + ? localWorkerLinkDelivery.data + : workerLinkDelivery + if (!currentDelivery || currentDelivery.delivery_status === 'SENT') return + + setMarkingLinkSent(true) + try { + const marked = await markWorkerLinkSent(currentDelivery.worker_link_id) + setLocalWorkerLinkDelivery({ taskId: task.task_id, data: marked }) + refetchWorkerLinkDelivery() + refetchActivities() + setDeliveryConfirmReturn('none') + setLinkOverlay('none') + showToast('근로자 링크 전달 완료를 기록했습니다.') + } catch (error) { + showToast( + error instanceof ApiError + ? getErrorMessage(error) + : '링크 전달 완료를 기록하지 못했습니다.', + ) + } finally { + setMarkingLinkSent(false) + } + } + + async function handleMarkResponsesRead() { + if (!task || markingResponsesRead) return + setMarkingResponsesRead(true) + try { + await markTaskWorkerResponsesRead(task.task_id) + refetchWorkerResponses() + refetchActivities() + showToast('근로자 응답을 확인 처리했습니다.') + } catch (error) { + showToast( + error instanceof ApiError + ? getErrorMessage(error) + : '근로자 응답을 확인 처리하지 못했습니다.', + ) + } finally { + setMarkingResponsesRead(false) + } + } + if (taskStatus === 'loading') { return (
@@ -336,7 +574,9 @@ export function CaseDetailPage() { const completedRequiredChecklist = requiredChecklist.filter((item) => item.completed).length const checklistReady = completedRequiredChecklist === requiredChecklist.length const informationReady = task.missing_required_slots.length === 0 - const documentsReady = readiness ? !readiness.completion_blocked : false + const documentsReady = + readinessStatus === 'success' && Boolean(readiness && !readiness.completion_blocked) + const documentsStateUnknown = readinessStatus === 'loading' || readinessStatus === 'error' const approvalReady = task.status === 'APPROVED' || task.status === 'WAITING_WORKER' || @@ -351,7 +591,7 @@ export function CaseDetailPage() { !approvalReady && '승인', !checklistReady && '필수 체크리스트', !informationReady && '필수 정보', - !documentsReady && '서류 준비', + !documentsReady && (documentsStateUnknown ? '서류 상태 확인' : '서류 준비'), ].filter(Boolean) as string[] const firstIncompleteChecklistIndex = task.checklist_items.findIndex((item) => !item.completed) const agentHeadline = @@ -361,6 +601,27 @@ export function CaseDetailPage() { const agentBody = completionBlockers.length ? `${completionBlockers.join(' · ')} 확인이 필요합니다.` : (task.description ?? '필수 항목과 서류가 모두 준비되었습니다.') + const unreadWorkerResponseCount = workerResponses.filter((response) => response.unread).length + const newestWorkerResponse = workerResponses[0] + const currentWorkerLinkDelivery = + localWorkerLinkDelivery?.taskId === task.task_id + ? localWorkerLinkDelivery.data + : workerLinkDelivery + const workerRequestState = getWorkerRequestStateViewModel({ + requestSentAt: + currentWorkerLinkDelivery?.delivery_status === 'SENT' + ? (currentWorkerLinkDelivery.sent_at ?? newestWorkerResponse?.received_at) + : newestWorkerResponse?.received_at, + responseReceivedAt: newestWorkerResponse?.received_at, + responseReadAt: + newestWorkerResponse && unreadWorkerResponseCount === 0 + ? newestWorkerResponse.received_at + : null, + completedAt: task.status === 'COMPLETED' ? task.updated_at : null, + }) + const caseDisplayStatus = caseProjection + ? getCaseDisplayStatusPresentation(caseProjection.display_status) + : null function handleAgentAction() { if (!task) return @@ -375,6 +636,15 @@ export function CaseDetailPage() { setActiveTab('체크리스트') } + function handleCloseContext() { + setContextDrawerOpen(false) + if (!searchParams.has('context')) return + + const nextParams = new URLSearchParams(searchParams) + nextParams.delete('context') + setSearchParams(nextParams, { replace: true }) + } + return (
@@ -555,16 +825,38 @@ export function CaseDetailPage() { + {readinessStatus === 'error' && ( +
+

+ {readinessError + ? getErrorMessage(readinessError) + : '서류 상태를 불러오지 못했습니다.'} +

+ +
+ )} - {/* TODO(backend): GET /api/work-items/:id/communication -> CASE_COMMUNICATION 대체 */} -
- {CASE_COMMUNICATION.map((entry) => ( -
- {entry.time} - {entry.actor} -

{entry.message}

-
- ))} -
+ {(workerResponsesStatus === 'loading' || workerLinkDeliveryStatus === 'loading') && ( + + )} + {(workerResponsesStatus === 'error' || workerLinkDeliveryStatus === 'error') && ( + { + refetchWorkerResponses() + refetchWorkerLinkDelivery() + }} + /> + )} + {(workerResponsesStatus === 'empty' || workerResponsesStatus === 'success') && + (workerLinkDeliveryStatus === 'empty' || workerLinkDeliveryStatus === 'success') && ( + <> +
+
+

근로자 모바일 요청

+
+

+ 응답 현황 +

+ + {workerRequestState.label} + +
+

{workerRequestState.description}

+
+ {unreadWorkerResponseCount > 0 && userRole !== 'VIEWER' && ( + + )} + {unreadWorkerResponseCount === 0 && + currentWorkerLinkDelivery?.delivery_status === 'NOT_SENT' && + userRole !== 'VIEWER' && ( + + )} +
+ + {workerResponses.length === 0 ? ( + + ) : ( +
+ {workerResponses.map((response) => ( +
+
+ + {formatEventTime(response.received_at)} + + + {response.unread ? '미확인' : '확인됨'} + +
+
+
+ 근로자 + + {WORKER_RESPONSE_TYPE_LABEL[response.response_type]} + +
+

+ {response.message?.trim() || '별도 메시지 없이 응답했습니다.'} +

+ {response.upload_ids.length > 0 && ( +

제출 파일 {response.upload_ids.length}개

+ )} +
+
+ ))} +
+ )} + + )}
)} @@ -819,7 +1208,11 @@ export function CaseDetailPage() { /> setCompletionOverlay('none')} + submissionAlreadyRecorded={task.status === 'WAITING_EXTERNAL'} + submitting={actionPending} + onClose={() => { + if (!actionPending) setCompletionOverlay('none') + }} onComplete={handleCompleteExternal} /> handleOpenDeliveryConfirm('reissued')} onClose={() => setLinkOverlay('none')} /> + setLinkOverlay(deliveryConfirmReturn)} + onConfirm={handleMarkLinkSent} + /> setContextDrawerOpen(false)} + onClose={handleCloseContext} title="관련 Context" > - {/* TODO(backend): GET /api/work-items/:id/context -> CONTEXT_DRAWER 대체 */} -
-

Agent가 확인한 내용

-
    - {CONTEXT_DRAWER.agentConfirmed.map((item) => ( -
  • {item}
  • - ))} -
-
+ {caseId && + (caseProjectionStatus === 'loading' || caseProjectionStatus === 'empty') && ( + + )} -
-

부족한 정보

-
    - {CONTEXT_DRAWER.missingInfo.map((item) => ( -
  • {item}
  • - ))} -
-
+ {caseProjectionStatus === 'error' && ( + + )} -
-

공식 출처

- {CONTEXT_DRAWER.officialSources.map((source) => ( - - ))} -
+ {!caseId && caseProjectionStatus === 'empty' && ( + + )} -
-

최근 활동

-
- {activityRows.slice(0, 3).map((entry, index) => ( -
- {formatEventTime(entry.created_at)} - - - {entry.change_summary ?? getAuditActionLabel(entry.action)} - -
- ))} -
-
+ {caseProjectionStatus === 'success' && caseProjection && caseDisplayStatus && ( + <> +
+

Case 현황

+ + + {caseDisplayStatus.label} + } + /> + + +
-
-

HR이 할 일

-
    - {CONTEXT_DRAWER.hrTodo.map((item) => ( -
  • {item}
  • - ))} -
-
+
+

준비 현황

+ + + + + +
+ +
+

연결된 업무

+ {caseProjection.tasks.length === 0 ? ( +

연결된 업무가 없습니다.

+ ) : ( + caseProjection.tasks.map((caseTask) => { + const status = getTaskStatusPresentation(caseTask.status) + return ( + + ) + }) + )} +
+ +
+

최근 활동

+ {activityRows.length === 0 ? ( +

기록된 활동이 없습니다.

+ ) : ( +
+ {activityRows.slice(0, 3).map((entry, index) => ( +
+ {formatEventTime(entry.created_at)} + + + {entry.change_summary ?? getAuditActionLabel(entry.action)} + +
+ ))} +
+ )} +
+ + )}
) diff --git a/src/pages/CaseDetailPage/caseDetailData.ts b/src/pages/CaseDetailPage/caseDetailData.ts index 55fad38..c28b878 100644 --- a/src/pages/CaseDetailPage/caseDetailData.ts +++ b/src/pages/CaseDetailPage/caseDetailData.ts @@ -1,45 +1 @@ -// TODO(backend): GET /api/work-items/:id -> 아래 상수 전체 대체 -// 제목·상태·체크리스트는 #153에서 실제 Task API로 대체됐다 (CaseDetailPage.tsx 참고). - export const CASE_TABS = ['현재 단계', '체크리스트', '문서', '소통', '활동이력'] - -export const AGENT_SUMMARY = { - headline: '여권 사본 요청문을 준비했습니다.', - body: '승인이 완료되면 72시간 보안 링크가 활성화됩니다. 실제 전달은 HR이 직접 수행합니다.', - actionLabel: '다음 행동 · 승인 요청', -} - -export const CONTEXT_ACCESS = { - label: '관련 Context', - rows: [ - { label: '근거', value: 4 }, - { label: '문서', value: 3 }, - { label: '활동', value: 8 }, - ], -} - -export const CONTEXT_DRAWER = { - agentConfirmed: [ - '응웬반A의 체류만료일이 2026-08-03로 확인됨', - '보유 서류 중 여권 사본, 표준근로계약서는 최신 상태', - ], - missingInfo: ['출입국 접수번호 미입력', '근로자 서명 확인 대기'], - officialSources: [ - { label: '하이코리아 체류자격 안내', value: '2026-07-18 확인' }, - { label: '출입국·외국인정책본부 고시', value: '2026-07-10 확인' }, - ], - hrTodo: ['근로자 안내문 최종 검토 후 승인 요청', '보안 링크 전달 전 담당자 직접 확인'], -} - -export interface CaseCommunicationEntry { - id: string - time: string - actor: string - message: string -} - -export const CASE_COMMUNICATION: CaseCommunicationEntry[] = [ - { id: 'comm-1', time: '오늘 09:12', actor: 'Agent', message: '여권 사본 요청문 초안을 준비했습니다.' }, - { id: 'comm-2', time: '어제 17:40', actor: '김경민', message: '근로자에게 서류 제출 안내 문자를 발송했습니다.' }, - { id: 'comm-3', time: '어제 09:05', actor: '응웬반A', message: '서류를 준비 중이라고 답장했습니다.' }, -] diff --git a/src/pages/CaseDetailPage/overlays/ExternalCompletionModal.tsx b/src/pages/CaseDetailPage/overlays/ExternalCompletionModal.tsx index a8f628b..35eee72 100644 --- a/src/pages/CaseDetailPage/overlays/ExternalCompletionModal.tsx +++ b/src/pages/CaseDetailPage/overlays/ExternalCompletionModal.tsx @@ -1,9 +1,16 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Modal } from '../../../components/ui/Modal/Modal' import styles from './overlays.module.css' type EvidenceType = '접수번호' | '파일' | '화면 캡처' +export interface ExternalCompletionSubmission { + destination: string + evidenceType: EvidenceType + evidenceValue: string + memo: string +} + const EVIDENCE_TYPES: EvidenceType[] = ['접수번호', '파일', '화면 캡처'] const EVIDENCE_PLACEHOLDER: Record = { @@ -14,25 +21,43 @@ const EVIDENCE_PLACEHOLDER: Record = { export interface ExternalCompletionModalProps { open: boolean + submissionAlreadyRecorded?: boolean + submitting?: boolean onClose: () => void - onComplete: (evidenceType: EvidenceType, evidenceValue: string, memo: string) => void + onComplete: (submission: ExternalCompletionSubmission) => void } -export function ExternalCompletionModal({ open, onClose, onComplete }: ExternalCompletionModalProps) { +export function ExternalCompletionModal({ + open, + submissionAlreadyRecorded = false, + submitting = false, + onClose, + onComplete, +}: ExternalCompletionModalProps) { + const [destination, setDestination] = useState('') const [evidenceType, setEvidenceType] = useState(null) const [evidenceValue, setEvidenceValue] = useState('') const [confirmed, setConfirmed] = useState(false) const [memo, setMemo] = useState('') - const isReady = evidenceType !== null && evidenceValue.trim() !== '' && confirmed - - function handleComplete() { - if (!isReady || evidenceType === null) return - onComplete(evidenceType, evidenceValue, memo) + useEffect(() => { + if (open) return + setDestination('') setEvidenceType(null) setEvidenceValue('') setConfirmed(false) setMemo('') + }, [open]) + + const isReady = + (submissionAlreadyRecorded || destination.trim() !== '') && + evidenceType !== null && + evidenceValue.trim() !== '' && + confirmed + + function handleComplete() { + if (!isReady || evidenceType === null) return + onComplete({ destination: destination.trim(), evidenceType, evidenceValue, memo }) } return ( @@ -47,6 +72,27 @@ export function ExternalCompletionModal({ open, onClose, onComplete }: ExternalC

완료할 수 없습니다 · 증빙을 등록해 주세요.

)} + {!submissionAlreadyRecorded && ( +
+ + setDestination(event.target.value)} + placeholder="예: 수원출입국·외국인청" + maxLength={160} + disabled={submitting} + /> +
+ )} + + {submissionAlreadyRecorded && ( +

외부기관 제출 기록이 확인되었습니다.

+ )} +

허용된 증빙 유형

{EVIDENCE_TYPES.map((type) => ( @@ -55,6 +101,7 @@ export function ExternalCompletionModal({ open, onClose, onComplete }: ExternalC type="button" className={`${styles.chip} ${evidenceType === type ? styles.chipSelected : ''}`} onClick={() => setEvidenceType(type)} + disabled={submitting} > {type} @@ -68,6 +115,8 @@ export function ExternalCompletionModal({ open, onClose, onComplete }: ExternalC value={evidenceValue} onChange={(event) => setEvidenceValue(event.target.value)} placeholder={EVIDENCE_PLACEHOLDER[evidenceType]} + maxLength={300} + disabled={submitting} />
)} @@ -77,12 +126,17 @@ export function ExternalCompletionModal({ open, onClose, onComplete }: ExternalC type="checkbox" checked={confirmed} onChange={(event) => setConfirmed(event.target.checked)} + disabled={submitting} /> 실제 제출은 담당자가 직접 수행했습니다. {!isReady && ( -

증빙과 담당자 확인을 완료해야 버튼이 활성화됩니다.

+

+ {submissionAlreadyRecorded + ? '증빙과 담당자 확인을 완료해야 버튼이 활성화됩니다.' + : '제출 기관, 증빙과 담당자 확인을 완료해야 버튼이 활성화됩니다.'} +

)} {isReady && ( @@ -91,15 +145,22 @@ export function ExternalCompletionModal({ open, onClose, onComplete }: ExternalC value={memo} onChange={(event) => setMemo(event.target.value)} placeholder="완료 메모 · 선택사항" + maxLength={500} + disabled={submitting} /> )}
- -
diff --git a/src/pages/CaseDetailPage/overlays/LinkDeliveryConfirmModal.tsx b/src/pages/CaseDetailPage/overlays/LinkDeliveryConfirmModal.tsx new file mode 100644 index 0000000..1377217 --- /dev/null +++ b/src/pages/CaseDetailPage/overlays/LinkDeliveryConfirmModal.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react' +import { Modal } from '../../../components/ui/Modal/Modal' +import styles from './overlays.module.css' + +export interface LinkDeliveryConfirmModalProps { + open: boolean + submitting?: boolean + onClose: () => void + onConfirm: () => void +} + +export function LinkDeliveryConfirmModal({ + open, + submitting = false, + onClose, + onConfirm, +}: LinkDeliveryConfirmModalProps) { + const [confirmed, setConfirmed] = useState(false) + + useEffect(() => { + if (!open) setConfirmed(false) + }, [open]) + + function handleConfirm() { + if (!confirmed || submitting) return + onConfirm() + } + + return ( + +

+ SMS나 메신저로 링크를 직접 전달한 경우에만 완료로 기록해 주세요. +

+ +
+

+ 이 기록은 근로자의 실제 수신을 보장하지 않으며, 업무 상태와 감사 이력에 반영됩니다. +

+
+ + + +
+ + +
+
+ ) +} diff --git a/src/pages/CaseDetailPage/overlays/LinkReissuedModal.tsx b/src/pages/CaseDetailPage/overlays/LinkReissuedModal.tsx index 044be9c..57b6d17 100644 --- a/src/pages/CaseDetailPage/overlays/LinkReissuedModal.tsx +++ b/src/pages/CaseDetailPage/overlays/LinkReissuedModal.tsx @@ -9,6 +9,8 @@ export interface LinkReissuedModalProps { submission: ReissueSubmission | null workerUrl: string | null expiresAt: string | null + canRecordDelivery: boolean + onRecordDelivery: () => void onClose: () => void } @@ -17,6 +19,8 @@ export function LinkReissuedModal({ submission, workerUrl, expiresAt, + canRecordDelivery, + onRecordDelivery, onClose, }: LinkReissuedModalProps) { const showToast = useToastStore((state) => state.showToast) @@ -60,8 +64,13 @@ export function LinkReissuedModal({ -
diff --git a/src/pages/CaseDetailPage/overlays/overlays.module.css b/src/pages/CaseDetailPage/overlays/overlays.module.css index 43d5751..fe1148e 100644 --- a/src/pages/CaseDetailPage/overlays/overlays.module.css +++ b/src/pages/CaseDetailPage/overlays/overlays.module.css @@ -89,6 +89,12 @@ cursor: pointer; } +.textLink:disabled, +.chip:disabled { + opacity: 0.42; + cursor: not-allowed; +} + .primaryButton { height: 48px; padding: 0 24px; diff --git a/src/pages/CaseDetailPage/overlays/overlays.test.tsx b/src/pages/CaseDetailPage/overlays/overlays.test.tsx index fbbb3b4..28e75d4 100644 --- a/src/pages/CaseDetailPage/overlays/overlays.test.tsx +++ b/src/pages/CaseDetailPage/overlays/overlays.test.tsx @@ -5,6 +5,7 @@ import { ApprovalRequestModal } from './ApprovalRequestModal' import { ApprovalDecisionModal } from './ApprovalDecisionModal' import { RejectionReasonModal } from './RejectionReasonModal' import { ExternalCompletionModal } from './ExternalCompletionModal' +import { LinkDeliveryConfirmModal } from './LinkDeliveryConfirmModal' describe('ApprovalRequestModal', () => { it('calls onSubmit when the request button is clicked', async () => { @@ -90,6 +91,7 @@ describe('ExternalCompletionModal', () => { expect(completeButton).toBeDisabled() expect(screen.getByText('완료할 수 없습니다 · 증빙을 등록해 주세요.')).toBeInTheDocument() + await user.type(screen.getByLabelText('제출 기관'), '수원출입국·외국인청') await user.click(screen.getByRole('button', { name: '접수번호' })) await user.type(screen.getByPlaceholderText('접수번호를 입력하세요'), 'HI-2026-0718-032') expect(completeButton).toBeDisabled() @@ -99,6 +101,59 @@ describe('ExternalCompletionModal', () => { expect(screen.getByText('✓ 완료 조건을 모두 충족했습니다.')).toBeInTheDocument() await user.click(completeButton) - expect(onComplete).toHaveBeenCalledWith('접수번호', 'HI-2026-0718-032', '') + expect(onComplete).toHaveBeenCalledWith({ + destination: '수원출입국·외국인청', + evidenceType: '접수번호', + evidenceValue: 'HI-2026-0718-032', + memo: '', + }) + }) + + it('does not request the destination again after an external submission was recorded', async () => { + const user = userEvent.setup() + const onComplete = vi.fn() + render( + , + ) + + expect(screen.queryByLabelText('제출 기관')).not.toBeInTheDocument() + expect(screen.getByText('외부기관 제출 기록이 확인되었습니다.')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '접수번호' })) + await user.type(screen.getByPlaceholderText('접수번호를 입력하세요'), 'HI-2026-0718-032') + await user.click(screen.getByLabelText('실제 제출은 담당자가 직접 수행했습니다.')) + await user.click(screen.getByRole('button', { name: '완료 처리' })) + + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ destination: '', evidenceValue: 'HI-2026-0718-032' }), + ) + }) +}) + +describe('LinkDeliveryConfirmModal', () => { + it('requires an explicit delivery confirmation before recording', async () => { + const user = userEvent.setup() + const onConfirm = vi.fn() + render( + , + ) + + const confirmButton = screen.getByRole('button', { name: '전달 완료로 기록' }) + expect(confirmButton).toBeDisabled() + + await user.click(screen.getByLabelText('근로자에게 링크를 직접 전달했습니다.')) + expect(confirmButton).toBeEnabled() + + await user.click(confirmButton) + expect(onConfirm).toHaveBeenCalledOnce() }) }) diff --git a/src/pages/WorkListPage/WorkInboxDetail.tsx b/src/pages/WorkListPage/WorkInboxDetail.tsx index fde3621..fad60c5 100644 --- a/src/pages/WorkListPage/WorkInboxDetail.tsx +++ b/src/pages/WorkListPage/WorkInboxDetail.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react' import { Button } from '../../components/ui/Button/Button' import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel' -import { useToastStore } from '../../store/toastStore' import type { WorkInboxWorkerGroup } from './workInboxModel' import { getCaseDisplayStatusPresentation, @@ -18,6 +17,7 @@ import styles from './WorkListPage.module.css' interface WorkInboxDetailProps { group: WorkInboxWorkerGroup onOpenTask: (taskId: string) => void + onOpenTaskContext: (taskId: string) => void onCreateWork: (workerId: string, workerDisplayName: string) => void onOpenWorker: (workerId: string) => void onOpenDocuments: (workerId: string) => void @@ -53,12 +53,12 @@ function getWorkerMeta(group: WorkInboxWorkerGroup): string { export function WorkInboxDetail({ group, onOpenTask, + onOpenTaskContext, onCreateWork, onOpenWorker, onOpenDocuments, casesUnavailable = false, }: WorkInboxDetailProps) { - const showToast = useToastStore((state) => state.showToast) const [activeCaseId, setActiveCaseId] = useState(group.primaryCase?.case_id ?? null) // 근로자를 바꾸면 새 근로자의 우선 Case로 되돌린다. @@ -138,7 +138,7 @@ export function WorkInboxDetail({ } function handleViewEvidence() { - showToast('판단 근거 보기는 준비 중입니다.') + if (activeTask) onOpenTaskContext(activeTask.task_id) } return ( @@ -257,7 +257,12 @@ export function WorkInboxDetail({

현재 결정

-