Skip to content
Draft
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
63 changes: 63 additions & 0 deletions src/api/approvals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TaskDetailResponse } from './tasks'
import {
approveTask,
buildTaskApprovalSnapshot,
completeTask,
recordTaskEvidence,
rejectTask,
requestTaskApproval,
} from './approvals'

function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}

function task(): TaskDetailResponse {
return {
task_id: 'T-1', 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',
updated_by: 'U-1', created_at: '2026-08-01T00:00:00Z', updated_at: '2026-08-01T00:00:00Z',
}
}

beforeEach(() => vi.stubGlobal('fetch', vi.fn()))
afterEach(() => vi.unstubAllGlobals())

describe('approval APIs', () => {
it('builds a server-compatible snapshot from the current Task version', () => {
expect(buildTaskApprovalSnapshot(task())).toEqual({
expected_version: 7,
ai_snapshot: null,
hr_snapshot: {
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'],
source_versions: { workflow_catalog_version: '3', content_revision: 2 },
})
})

it('uses the approval, decision, 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 recordTaskEvidence('T-1', { evidence_type: 'RECEIPT', note: '접수번호 1234' })
await completeTask('T-1', 9)

const calls = vi.mocked(fetch).mock.calls
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 })
})
})
112 changes: 112 additions & 0 deletions src/api/approvals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { apiFetch } from './client'
import type { TaskDetailResponse, TaskStatus } from './tasks'

export type ApprovalStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'INVALIDATED'

export interface ApprovalResponse {
approval_request_id: string
task_id: string
approval_status: ApprovalStatus
task_status: TaskStatus
content_revision: number
task_version: number
requested_at: string
decided_at: string | null
}

export interface RequestTaskApprovalBody {
expected_version: number
ai_snapshot: Record<string, unknown> | null
hr_snapshot: Record<string, unknown>
changed_fields: string[]
source_versions: Record<string, unknown>
}

export interface DecideTaskApprovalBody {
expected_version: number
reason?: string
}

export type EvidenceType = 'DOCUMENT' | 'RECEIPT' | 'OFFICIAL_RESULT' | 'HR_CONFIRMATION'

export interface RecordTaskEvidenceBody {
evidence_type: EvidenceType
file_reference?: string
note?: string
recorded_at?: string
}

export interface TaskActionResponse {
resource_id: string
task_id: string
task_status: TaskStatus
task_version: number
}

export function buildTaskApprovalSnapshot(task: TaskDetailResponse): RequestTaskApprovalBody {
return {
expected_version: task.version,
ai_snapshot: null,
hr_snapshot: {
worker_id: task.worker_id,
task_type: task.task_type,
workflow_id: task.workflow_id,
title: task.title,
description: task.description,
due_date: task.due_date,
business_data: task.business_data,
},
changed_fields: ['task_content'],
source_versions: {
workflow_catalog_version: task.workflow_catalog_version,
content_revision: task.content_revision,
},
}
}

export function requestTaskApproval(
taskId: string,
body: RequestTaskApprovalBody,
): Promise<ApprovalResponse> {
return apiFetch<ApprovalResponse>(`/tasks/${encodeURIComponent(taskId)}/approval-requests`, {
method: 'POST',
body: JSON.stringify(body),
})
}

export function approveTask(
taskId: string,
body: DecideTaskApprovalBody,
): Promise<ApprovalResponse> {
return apiFetch<ApprovalResponse>(`/tasks/${encodeURIComponent(taskId)}/approve`, {
method: 'POST',
body: JSON.stringify(body),
})
}

export function rejectTask(
taskId: string,
body: Required<Pick<DecideTaskApprovalBody, 'expected_version' | 'reason'>>,
): Promise<ApprovalResponse> {
return apiFetch<ApprovalResponse>(`/tasks/${encodeURIComponent(taskId)}/reject`, {
method: 'POST',
body: JSON.stringify(body),
})
}

export function recordTaskEvidence(
taskId: string,
body: RecordTaskEvidenceBody,
): Promise<TaskActionResponse> {
return apiFetch<TaskActionResponse>(`/tasks/${encodeURIComponent(taskId)}/evidence`, {
method: 'POST',
body: JSON.stringify(body),
})
}

export function completeTask(taskId: string, expectedVersion: number): Promise<TaskActionResponse> {
return apiFetch<TaskActionResponse>(`/tasks/${encodeURIComponent(taskId)}/complete`, {
method: 'POST',
body: JSON.stringify({ expected_version: expectedVersion }),
})
}
4 changes: 4 additions & 0 deletions src/pages/CaseDetailPage/CaseDetailPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@
color: var(--text-secondary);
}

.currentStateRows {
margin-bottom: var(--fowoco-spacing-20);
}

.stepList {
margin-top: 28px;
display: flex;
Expand Down
Loading