diff --git a/src/api/approvals.test.ts b/src/api/approvals.test.ts index 6c5ebaa..50b67ff 100644 --- a/src/api/approvals.test.ts +++ b/src/api/approvals.test.ts @@ -37,6 +37,9 @@ function task(): TaskDetailResponse { version: 7, missing_required_slots: [], checklist_items: [], + next_action: 'REQUEST_APPROVAL', + available_actions: ['REQUEST_APPROVAL'], + blocked_reason: 'APPROVAL_REQUIRED_BEFORE_CONTINUATION', created_by: 'U-1', updated_by: 'U-1', created_at: '2026-08-01T00:00:00Z', diff --git a/src/api/errors.test.ts b/src/api/errors.test.ts index 43fbdcb..161a154 100644 --- a/src/api/errors.test.ts +++ b/src/api/errors.test.ts @@ -16,7 +16,9 @@ function makeBody(overrides: Partial[0]> describe('ApiError', () => { it('exposes status/code/requestId/fieldErrors from the response body', () => { - const error = new ApiError(makeBody({ field_errors: [{ field: 'email', message: '형식이 올바르지 않습니다.' }] })) + const error = new ApiError( + makeBody({ field_errors: [{ field: 'email', message: '형식이 올바르지 않습니다.' }] }), + ) expect(error.status).toBe(404) expect(error.code).toBe('RESOURCE_NOT_FOUND') @@ -46,6 +48,24 @@ describe('getErrorMessage', () => { const error = new ApiError(makeBody({ code: 'EMAIL_ALREADY_REGISTERED', message: 'raw' })) expect(getErrorMessage(error)).toBe('이미 가입된 이메일입니다.') }) + + it.each([ + [ + 'RENEWAL_EXECUTION_NOT_ALLOWED', + '현재 업무 단계에서는 Agent를 다시 실행할 수 없습니다. 화면에 안내된 다음 행동을 진행해 주세요.', + ], + [ + 'RENEWAL_REQUEST_CONTRACT_INVALID', + '업무 정보가 Agent 요청 계약과 맞지 않습니다. 새로고침한 뒤 입력 내용을 확인해 주세요.', + ], + [ + 'RENEWAL_WORKFLOW_MISMATCH', + '업무 유형과 Agent Workflow가 일치하지 않습니다. 업무 설정을 확인해 주세요.', + ], + ])('maps %s to an actionable Korean message', (code, message) => { + const error = new ApiError(makeBody({ code, message: 'raw' })) + expect(getErrorMessage(error)).toBe(message) + }) }) describe('networkApiError', () => { diff --git a/src/api/errors.ts b/src/api/errors.ts index 2a0f292..614350f 100644 --- a/src/api/errors.ts +++ b/src/api/errors.ts @@ -55,6 +55,12 @@ const ERROR_MESSAGES: Record = { // 화면에는 그 원문 대신 이 문구를 보여준다. UNKNOWN_ERROR: '요청을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.', EMAIL_ALREADY_REGISTERED: '이미 가입된 이메일입니다.', + RENEWAL_EXECUTION_NOT_ALLOWED: + '현재 업무 단계에서는 Agent를 다시 실행할 수 없습니다. 화면에 안내된 다음 행동을 진행해 주세요.', + RENEWAL_REQUEST_CONTRACT_INVALID: + '업무 정보가 Agent 요청 계약과 맞지 않습니다. 새로고침한 뒤 입력 내용을 확인해 주세요.', + RENEWAL_WORKFLOW_MISMATCH: + '업무 유형과 Agent Workflow가 일치하지 않습니다. 업무 설정을 확인해 주세요.', } const DEFAULT_MESSAGE = '알 수 없는 오류가 발생했습니다.' diff --git a/src/api/tasks.ts b/src/api/tasks.ts index 6bb2c95..14b5973 100644 --- a/src/api/tasks.ts +++ b/src/api/tasks.ts @@ -22,6 +22,18 @@ export type TaskType = | 'WORK_INSTRUCTION' export type TaskTargetType = 'WORKER' | 'COMPANY' export type TaskSource = 'MANUAL' | 'SYSTEM_DDAY' | 'AI_CANDIDATE' +export type TaskAvailableAction = + | 'RUN_RENEWAL' + | 'PROVIDE_REQUIRED_INFORMATION' + | 'COMPLETE_CHECKLIST' + | 'REVIEW_OCR' + | 'REVIEW_WORKER_GUIDE' + | 'REVIEW_GENERATED_DOCUMENT' + | 'REQUEST_APPROVAL' + | 'APPROVE' + | 'ISSUE_WORKER_LINK' + | 'REVIEW_WORKER_RESPONSE' + | 'COMPLETE_TASK' export interface TaskChecklistItemResponse { checklist_item_id: string @@ -53,6 +65,9 @@ export interface TaskDetailResponse { version: number missing_required_slots: string[] checklist_items: TaskChecklistItemResponse[] + next_action: TaskAvailableAction | null + available_actions: TaskAvailableAction[] + blocked_reason: string | null created_by: string updated_by: string created_at: string diff --git a/src/pages/CaseDetailPage/CaseDetailPage.module.css b/src/pages/CaseDetailPage/CaseDetailPage.module.css index 37a59ff..4d15901 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.module.css +++ b/src/pages/CaseDetailPage/CaseDetailPage.module.css @@ -92,6 +92,33 @@ margin-top: 20px; } +.nextActionNotice { + display: flex; + align-items: baseline; + gap: var(--fowoco-spacing-12); + margin-top: var(--fowoco-spacing-20); + padding: var(--fowoco-spacing-12) var(--fowoco-spacing-16); + border: 1px solid var(--fowoco-amber-200); + border-radius: var(--fowoco-radius-10); + background: var(--fowoco-amber-50); + color: var(--text-primary); + font-size: 13px; + line-height: 20px; +} + +.nextActionNotice strong { + flex: 0 0 auto; + color: var(--fowoco-amber-700); +} + +@media (max-width: 720px) { + .nextActionNotice { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } +} + @media (max-width: 1100px) { .summaryRow { grid-template-columns: 1fr; diff --git a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx index d4425ac..bfabca0 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx @@ -42,7 +42,7 @@ function errorResponse(status: number, code: string, message: string) { } function task(overrides: Partial = {}): TaskDetailResponse { - return { + const result: TaskDetailResponse = { task_id: 'T-1', target_type: 'WORKER', worker_id: 'W-1', @@ -82,12 +82,43 @@ function task(overrides: Partial = {}): TaskDetailResponse { version: 1, }, ], + next_action: 'APPROVE', + available_actions: ['APPROVE'], + blocked_reason: 'HR_REVIEW_REQUIRED', created_by: 'u-1', updated_by: 'u-1', created_at: '2026-07-01T00:00:00Z', updated_at: '2026-07-01T00:00:00Z', ...overrides, } + if (overrides.status && overrides.next_action === undefined) { + const decision = { + DRAFT: ['REQUEST_APPROVAL', ['REQUEST_APPROVAL'], 'APPROVAL_REQUIRED_BEFORE_CONTINUATION'], + NEEDS_INFO: [ + 'REQUEST_APPROVAL', + ['REQUEST_APPROVAL'], + 'APPROVAL_REQUIRED_BEFORE_CONTINUATION', + ], + READY_FOR_REVIEW: ['APPROVE', ['APPROVE'], 'HR_REVIEW_REQUIRED'], + APPROVED: ['ISSUE_WORKER_LINK', ['ISSUE_WORKER_LINK'], null], + WAITING_WORKER: [ + 'REVIEW_WORKER_RESPONSE', + ['REVIEW_WORKER_RESPONSE'], + 'WORKER_RESPONSE_PENDING', + ], + WAITING_EXTERNAL: ['COMPLETE_TASK', ['COMPLETE_TASK'], 'EXTERNAL_EVIDENCE_REQUIRED'], + COMPLETED: [null, [], 'TASK_TERMINAL'], + CANCELLED: [null, [], 'TASK_TERMINAL'], + }[overrides.status] as [ + TaskDetailResponse['next_action'], + TaskDetailResponse['available_actions'], + string | null, + ] + result.next_action = decision[0] + result.available_actions = decision[1] + result.blocked_reason = decision[2] + } + return result } function activity(overrides: Partial = {}): AuditEventResponse { @@ -1109,6 +1140,7 @@ describe('CaseDetailPage', () => { }) it('allows an approval request before missing worker documents are collected', async () => { + const user = userEvent.setup() mockTaskAndActivities( { status: 'DRAFT', @@ -1134,6 +1166,9 @@ describe('CaseDetailPage', () => { expect(screen.getByRole('button', { name: '승인 요청' })).toBeEnabled() expect(screen.getByText('누락 1건 · 만료 0건')).toBeInTheDocument() + expect(screen.getAllByText('다음 행동 · 승인 요청').length).toBeGreaterThan(0) + await user.click(screen.getByRole('button', { name: '더보기 ···' })) + expect(screen.queryByRole('menuitem', { name: 'Renewal 실행' })).not.toBeInTheDocument() }) it('requires Renewal preparation before requesting approval for a renewal task', async () => { @@ -1141,6 +1176,9 @@ describe('CaseDetailPage', () => { mockTaskAndActivities({ status: 'DRAFT', business_data: {}, + next_action: 'RUN_RENEWAL', + available_actions: ['RUN_RENEWAL'], + blocked_reason: null, checklist_items: [ { checklist_item_id: 'chk-1', @@ -1162,16 +1200,20 @@ describe('CaseDetailPage', () => { expect(screen.getByRole('dialog', { name: 'Renewal Agent 실행' })).toBeInTheDocument() }) - it('keeps Renewal retry available while waiting for worker information', async () => { + it('guides the user to review worker responses instead of rerunning Renewal', async () => { const user = userEvent.setup() - mockTaskAndActivities({ status: 'WAITING_WORKER' }) + mockTaskAndActivities({ + status: 'WAITING_WORKER', + next_action: 'REVIEW_WORKER_RESPONSE', + available_actions: ['REVIEW_WORKER_RESPONSE'], + blocked_reason: 'WORKER_RESPONSE_PENDING', + }) renderPage() await screen.findByText('응웬반A 체류연장 준비') await user.click(screen.getByRole('button', { name: '더보기 ···' })) - await user.click(screen.getByRole('menuitem', { name: 'Renewal 실행' })) - - expect(screen.getByRole('dialog', { name: 'Renewal Agent 실행' })).toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: 'Renewal 실행' })).not.toBeInTheDocument() + expect(screen.getAllByText('다음 행동 · 근로자 응답 확인').length).toBeGreaterThan(0) }) it('approves through the API instead of setting a local success state', async () => { @@ -1210,7 +1252,12 @@ describe('CaseDetailPage', () => { it('opens and closes the more menu', async () => { const user = userEvent.setup() - mockTaskAndActivities({ status: 'DRAFT' }) + mockTaskAndActivities({ + status: 'DRAFT', + next_action: 'RUN_RENEWAL', + available_actions: ['RUN_RENEWAL'], + blocked_reason: null, + }) renderPage() await screen.findByText('응웬반A 체류연장 준비') @@ -1229,7 +1276,11 @@ describe('CaseDetailPage', () => { it('does not show Renewal 실행 for non-renewal task types', async () => { const user = userEvent.setup() - mockTaskAndActivities({ task_type: 'DOCUMENT_REQUEST' }) + mockTaskAndActivities({ + task_type: 'DOCUMENT_REQUEST', + next_action: 'REQUEST_APPROVAL', + available_actions: ['REQUEST_APPROVAL'], + }) renderPage() await screen.findByText('응웬반A 체류연장 준비') @@ -1239,7 +1290,12 @@ describe('CaseDetailPage', () => { it('opens the Renewal execution modal from the more menu', async () => { const user = userEvent.setup() - mockTaskAndActivities({ status: 'DRAFT' }) + mockTaskAndActivities({ + status: 'DRAFT', + next_action: 'RUN_RENEWAL', + available_actions: ['RUN_RENEWAL'], + blocked_reason: null, + }) renderPage() await screen.findByText('응웬반A 체류연장 준비') diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx index b144802..0449804 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx @@ -28,6 +28,7 @@ import { changeTaskAssignee, fetchTaskById, updateChecklistItem, + type TaskAvailableAction, type TaskType, } from '../../api/tasks' import { fetchWorkerById } from '../../api/workers' @@ -147,6 +148,57 @@ const RENEWAL_TASK_TYPES = new Set([ 'STAY_PERIOD_EXTENSION', ]) +const NEXT_ACTION_PRESENTATION: Record< + TaskAvailableAction, + { label: string; description: string } +> = { + RUN_RENEWAL: { + label: 'Renewal 실행', + description: 'Agent가 현재 정보와 누락 항목을 다시 확인할 수 있습니다.', + }, + PROVIDE_REQUIRED_INFORMATION: { + label: '필수 정보 보완', + description: '업무 진행에 필요한 정보를 먼저 입력해 주세요.', + }, + COMPLETE_CHECKLIST: { + label: '체크리스트 확인', + description: '남아 있는 필수 체크리스트를 완료해 주세요.', + }, + REVIEW_OCR: { + label: 'OCR 결과 검토', + description: '근로자가 제출한 서류의 OCR 결과를 확인해 주세요.', + }, + REVIEW_WORKER_GUIDE: { + label: '근로자 안내문 검토', + description: '대상 언어와 안내 내용을 확인한 뒤 초안을 저장해 주세요.', + }, + REVIEW_GENERATED_DOCUMENT: { + label: '생성 문서 검토', + description: 'Agent가 만든 문서를 확인한 뒤 승인 절차를 진행해 주세요.', + }, + REQUEST_APPROVAL: { + label: '승인 요청', + description: + 'Agent 결과가 반영되어 기존 승인이 무효화되었습니다. 현재 버전으로 다시 승인받아 주세요.', + }, + APPROVE: { + label: '승인 검토', + description: '담당자가 제출한 현재 업무 버전을 검토해 주세요.', + }, + ISSUE_WORKER_LINK: { + label: '근로자 링크 발급', + description: '승인된 요청을 근로자에게 전달할 보안 링크를 준비해 주세요.', + }, + REVIEW_WORKER_RESPONSE: { + label: '근로자 응답 확인', + description: '근로자가 제출한 답변과 서류를 확인해 주세요.', + }, + COMPLETE_TASK: { + label: '완료 처리', + description: '실행 결과와 증빙을 확인한 뒤 업무를 완료해 주세요.', + }, +} + async function fetchTaskWorkerLinkDeliveryOrNull( taskId: string, ): Promise { @@ -981,8 +1033,10 @@ export function CaseDetailPage() { !isRenewalTask || (typeof task.business_data.renewal_execution === 'object' && task.business_data.renewal_execution !== null) - const canRunRenewal = isRenewalTask && task.status !== 'COMPLETED' && task.status !== 'CANCELLED' - const renewalPreparationRequired = canRunRenewal && !renewalPrepared + const availableActions = task.available_actions ?? [] + const nextAction = task.next_action ?? null + const nextActionPresentation = nextAction ? NEXT_ACTION_PRESENTATION[nextAction] : null + const canRunRenewal = availableActions.includes('RUN_RENEWAL') const approvalReady = task.status === 'APPROVED' || task.status === 'WAITING_WORKER' || @@ -992,11 +1046,7 @@ export function CaseDetailPage() { Boolean(documentRequestDraftForm?.message.trim()) const taskCompleted = task.status === 'COMPLETED' const approvalSatisfied = approvalReady || taskCompleted - const canRequestApproval = - (task.status === 'DRAFT' || task.status === 'NEEDS_INFO') && - checklistReady && - informationReady && - renewalPrepared + const canRequestApproval = availableActions.includes('REQUEST_APPROVAL') const canComplete = !taskCompleted && approvalReady && checklistReady && informationReady && documentsReady const completionBlockers = [ @@ -1045,14 +1095,20 @@ export function CaseDetailPage() { setActiveTab('활동이력') return } - if (renewalPreparationRequired) { - setRenewalOverlayOpen(true) - return + if (nextAction === 'RUN_RENEWAL') return setRenewalOverlayOpen(true) + if (nextAction === 'REQUEST_APPROVAL') return handleOpenApprovalRequest() + if (nextAction === 'APPROVE') return handleOpenReview() + if ( + nextAction === 'REVIEW_OCR' || + nextAction === 'REVIEW_WORKER_GUIDE' || + nextAction === 'REVIEW_GENERATED_DOCUMENT' + ) { + return setActiveTab('문서') } - if (task.status === 'READY_FOR_REVIEW') { - handleOpenReview() - return + if (nextAction === 'ISSUE_WORKER_LINK' || nextAction === 'REVIEW_WORKER_RESPONSE') { + return setActiveTab('소통') } + if (nextAction === 'COMPLETE_TASK' && canComplete) return handleOpenExternalCompletion() if (!documentsReady) { setActiveTab('문서') return @@ -1151,6 +1207,12 @@ export function CaseDetailPage() { {activeTab === '현재 단계' && (
+ {nextActionPresentation && ( +
+ 다음 행동 · {nextActionPresentation.label} + {nextActionPresentation.description} +
+ )}
@@ -1789,8 +1853,8 @@ export function CaseDetailPage() {
- {task.status === 'READY_FOR_REVIEW' - ? '다음 행동 · 승인 검토' + {nextActionPresentation + ? `다음 행동 · ${nextActionPresentation.label}` : task.status === 'COMPLETED' ? '이 업무는 완료되었습니다.' : task.status === 'CANCELLED' @@ -1799,7 +1863,7 @@ export function CaseDetailPage() { ? '다음 행동 · 실행 결과와 증빙 확인' : '다음 행동 · 필수 조건 확인 후 승인 요청'} - {task.status === 'READY_FOR_REVIEW' && ( + {availableActions.includes('APPROVE') && (