Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/api/approvals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
22 changes: 21 additions & 1 deletion src/api/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ function makeBody(overrides: Partial<ConstructorParameters<typeof ApiError>[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')
Expand Down Expand Up @@ -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', () => {
Expand Down
6 changes: 6 additions & 0 deletions src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ const ERROR_MESSAGES: Record<string, string> = {
// 화면에는 그 원문 대신 이 문구를 보여준다.
UNKNOWN_ERROR: '요청을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.',
EMAIL_ALREADY_REGISTERED: '이미 가입된 이메일입니다.',
RENEWAL_EXECUTION_NOT_ALLOWED:
'현재 업무 단계에서는 Agent를 다시 실행할 수 없습니다. 화면에 안내된 다음 행동을 진행해 주세요.',
RENEWAL_REQUEST_CONTRACT_INVALID:
'업무 정보가 Agent 요청 계약과 맞지 않습니다. 새로고침한 뒤 입력 내용을 확인해 주세요.',
RENEWAL_WORKFLOW_MISMATCH:
'업무 유형과 Agent Workflow가 일치하지 않습니다. 업무 설정을 확인해 주세요.',
}

const DEFAULT_MESSAGE = '알 수 없는 오류가 발생했습니다.'
Expand Down
15 changes: 15 additions & 0 deletions src/api/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/pages/CaseDetailPage/CaseDetailPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
74 changes: 65 additions & 9 deletions src/pages/CaseDetailPage/CaseDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function errorResponse(status: number, code: string, message: string) {
}

function task(overrides: Partial<TaskDetailResponse> = {}): TaskDetailResponse {
return {
const result: TaskDetailResponse = {
task_id: 'T-1',
target_type: 'WORKER',
worker_id: 'W-1',
Expand Down Expand Up @@ -82,12 +82,43 @@ function task(overrides: Partial<TaskDetailResponse> = {}): 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> = {}): AuditEventResponse {
Expand Down Expand Up @@ -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',
Expand All @@ -1134,13 +1166,19 @@ 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 () => {
const user = userEvent.setup()
mockTaskAndActivities({
status: 'DRAFT',
business_data: {},
next_action: 'RUN_RENEWAL',
available_actions: ['RUN_RENEWAL'],
blocked_reason: null,
checklist_items: [
{
checklist_item_id: 'chk-1',
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 체류연장 준비')

Expand All @@ -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 체류연장 준비')

Expand All @@ -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 체류연장 준비')

Expand Down
Loading