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
41 changes: 33 additions & 8 deletions src/api/approvals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,28 @@ function jsonResponse(body: unknown, status = 200) {

function task(): TaskDetailResponse {
return {
task_id: 'T-1', target_type: 'WORKER', worker_id: 'W-1', case_id: null, task_type: 'STAY_PERIOD_EXTENSION',
workflow_id: 'wf-stay', workflow_catalog_version: '3', title: '체류기간 연장', description: '안내',
business_data: { office: '수원' }, source: 'MANUAL', status: 'DRAFT', due_date: '2026-08-10',
content_revision: 2, version: 7, missing_required_slots: [], checklist_items: [], created_by: 'U-1',
updated_by: 'U-1', created_at: '2026-08-01T00:00:00Z', updated_at: '2026-08-01T00:00:00Z',
task_id: 'T-1',
target_type: 'WORKER',
worker_id: 'W-1',
case_id: null,
task_type: 'STAY_PERIOD_EXTENSION',
workflow_id: 'wf-stay',
workflow_catalog_version: '3',
title: '체류기간 연장',
description: '안내',
business_data: { office: '수원' },
source: 'MANUAL',
status: 'DRAFT',
due_date: '2026-08-10',
assignee: { user_id: 'U-1', display_name: '김현준' },
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',
}
}

Expand All @@ -36,16 +53,24 @@ describe('approval APIs', () => {
expected_version: 7,
ai_snapshot: null,
hr_snapshot: {
target_type: 'WORKER', worker_id: 'W-1', task_type: 'STAY_PERIOD_EXTENSION', workflow_id: 'wf-stay',
title: '체류기간 연장', description: '안내', due_date: '2026-08-10', business_data: { office: '수원' },
target_type: 'WORKER',
worker_id: 'W-1',
task_type: 'STAY_PERIOD_EXTENSION',
workflow_id: 'wf-stay',
title: '체류기간 연장',
description: '안내',
due_date: '2026-08-10',
business_data: { office: '수원' },
},
changed_fields: ['task_content'],
source_versions: { workflow_catalog_version: '3', content_revision: 2 },
})
})

it('uses the approval, decision, external submission, evidence and completion endpoints', async () => {
vi.mocked(fetch).mockImplementation(() => Promise.resolve(jsonResponse({ task_id: 'T-1' }, 201)))
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 })
Expand Down
31 changes: 29 additions & 2 deletions src/api/tasks.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cancelTask, createTask, fetchTaskById, fetchTasks, updateChecklistItem, updateTask } from './tasks'
import {
cancelTask,
changeTaskAssignee,
createTask,
fetchTaskById,
fetchTasks,
updateChecklistItem,
updateTask,
} from './tasks'

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

beforeEach(() => {
Expand Down Expand Up @@ -115,6 +126,22 @@ describe('updateTask', () => {
})
})

describe('changeTaskAssignee', () => {
it('PATCHes /tasks/{id}/assignee with the selected member and task version', async () => {
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ task_id: 'T-1' }))

await changeTaskAssignee('T-1', { assignee_id: 'U-2', expected_version: 3 })

const [url, init] = vi.mocked(fetch).mock.calls[0]
expect(url).toContain('/tasks/T-1/assignee')
expect(init?.method).toBe('PATCH')
expect(JSON.parse(init?.body as string)).toEqual({
assignee_id: 'U-2',
expected_version: 3,
})
})
})

describe('updateChecklistItem', () => {
it('PATCHes the checklist item path', async () => {
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ task_id: 'T-1' }))
Expand Down
21 changes: 21 additions & 0 deletions src/api/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface TaskDetailResponse {
source: TaskSource
status: TaskStatus
due_date: string | null
assignee: TaskAssigneeResponse
content_revision: number
version: number
missing_required_slots: string[]
Expand All @@ -58,6 +59,11 @@ export interface TaskDetailResponse {
updated_at: string
}

export interface TaskAssigneeResponse {
user_id: string
display_name: string
}

export interface TaskSummaryResponse {
task_id: string
target_type: TaskTargetType
Expand Down Expand Up @@ -150,6 +156,21 @@ export function updateTask(taskId: string, body: UpdateTaskBody): Promise<TaskDe
})
}

export interface ChangeTaskAssigneeBody {
assignee_id: string
expected_version: number
}

export function changeTaskAssignee(
taskId: string,
body: ChangeTaskAssigneeBody,
): Promise<TaskDetailResponse> {
return apiFetch<TaskDetailResponse>(`/tasks/${encodeURIComponent(taskId)}/assignee`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}

export interface UpdateChecklistItemBody {
completed: boolean
expected_version: number
Expand Down
38 changes: 36 additions & 2 deletions src/pages/CaseDetailPage/CaseDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ function task(overrides: Partial<TaskDetailResponse> = {}): TaskDetailResponse {
source: 'MANUAL',
status: 'READY_FOR_REVIEW',
due_date: '2026-08-01',
assignee: { user_id: 'U-1', display_name: '김현준' },
content_revision: 1,
version: 1,
missing_required_slots: [],
Expand Down Expand Up @@ -188,6 +189,22 @@ function mockTaskAndActivities(
let currentDocumentRequestDraft = savedDocumentRequestDraft
vi.mocked(fetch).mockImplementation((input, init) => {
const url = String(input)
if (url.includes('/company-members')) {
return Promise.resolve(
jsonResponse({
items: [
{ user_id: 'U-1', display_name: '김현준', roles: ['HR'], active: true },
{ user_id: 'U-2', display_name: '김채린', roles: ['HR'], active: true },
{ user_id: 'U-3', display_name: '조회 전용', roles: ['VIEWER'], active: true },
],
}),
)
}
if (url.endsWith('/tasks/T-1/assignee')) {
return Promise.resolve(
jsonResponse(task({ assignee: { user_id: 'U-2', display_name: '김채린' }, version: 2 })),
)
}
if (url.includes('/activities')) return Promise.resolve(jsonResponse(activities))
if (url.includes('/workers/W-1')) {
return Promise.resolve(
Expand Down Expand Up @@ -1140,7 +1157,7 @@ describe('CaseDetailPage', () => {
expect(screen.getByRole('dialog', { name: 'Renewal Agent 실행' })).toBeInTheDocument()
})

it('shows a toast when the assignee change action is clicked', async () => {
it('changes the task assignee from the more menu', async () => {
const user = userEvent.setup()
mockTaskAndActivities()
renderPage()
Expand All @@ -1149,8 +1166,25 @@ describe('CaseDetailPage', () => {
await user.click(screen.getByRole('button', { name: '더보기 ···' }))
await user.click(screen.getByRole('menuitem', { name: '담당자 변경' }))

expect(screen.getByText('담당자 변경은 준비 중입니다.')).toBeInTheDocument()
const dialog = await screen.findByRole('dialog', { name: '담당자 변경' })
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(within(dialog).queryByRole('option', { name: /조회 전용/ })).not.toBeInTheDocument()
await user.selectOptions(within(dialog).getByLabelText('새 담당자'), 'U-2')
await user.click(within(dialog).getByRole('button', { name: '담당자 변경' }))

const changeCall = await waitFor(() => {
const call = vi
.mocked(fetch)
.mock.calls.find(([url]) => String(url).endsWith('/tasks/T-1/assignee'))
expect(call).toBeDefined()
return call!
})
expect(changeCall[1]?.method).toBe('PATCH')
expect(JSON.parse(changeCall[1]?.body as string)).toEqual({
assignee_id: 'U-2',
expected_version: 1,
})
expect(await screen.findByText('김채린님에게 담당 업무를 변경했습니다.')).toBeInTheDocument()
})

it('cancels the task via the more menu when a reason is entered', async () => {
Expand Down
93 changes: 79 additions & 14 deletions src/pages/CaseDetailPage/CaseDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ import {
} from '../../api/documents'
import { ApiError, getErrorMessage } from '../../api/errors'
import { downloadFile } from '../../api/files'
import { cancelTask, fetchTaskById, updateChecklistItem, type TaskType } from '../../api/tasks'
import { fetchCompanyMembers, type CompanyMemberItemResponse } from '../../api/settings'
import {
cancelTask,
changeTaskAssignee,
fetchTaskById,
updateChecklistItem,
type TaskType,
} from '../../api/tasks'
import { fetchWorkerById } from '../../api/workers'
import {
adoptWorkerResponseDocuments,
Expand Down Expand Up @@ -71,6 +78,7 @@ import {
} from '../WorkListPage/workInboxPresentation'
import { ApprovalDecisionModal } from './overlays/ApprovalDecisionModal'
import { ApprovalRequestModal } from './overlays/ApprovalRequestModal'
import { AssigneeChangeModal } from './overlays/AssigneeChangeModal'
import {
ExternalCompletionModal,
type ExternalCompletionSubmission,
Expand Down Expand Up @@ -246,6 +254,11 @@ export function CaseDetailPage() {
const [approvalOverlay, setApprovalOverlay] = useState<ApprovalOverlay>('none')
const [completionOverlay, setCompletionOverlay] = useState<CompletionOverlay>('none')
const [actionPending, setActionPending] = useState(false)
const [assigneeOverlayOpen, setAssigneeOverlayOpen] = useState(false)
const [assigneeMembers, setAssigneeMembers] = useState<CompanyMemberItemResponse[]>([])
const [assigneeMembersLoading, setAssigneeMembersLoading] = useState(false)
const [assigneeSubmitting, setAssigneeSubmitting] = useState(false)
const [assigneeErrorMessage, setAssigneeErrorMessage] = useState<string | null>(null)
const [togglingItemId, setTogglingItemId] = useState<string | null>(null)
const [linkOverlay, setLinkOverlay] = useState<LinkOverlay>('none')
const [deliveryConfirmReturn, setDeliveryConfirmReturn] = useState<DeliveryConfirmReturn>('none')
Expand Down Expand Up @@ -632,10 +645,48 @@ export function CaseDetailPage() {
}
}

function handleReassignCase() {
// TODO(backend): PATCH /api/work-items/:id/assignee -> 담당자 변경 (이슈 #315)
async function handleReassignCase() {
setMoreMenuOpen(false)
showToast('담당자 변경은 준비 중입니다.')
setAssigneeOverlayOpen(true)
setAssigneeMembersLoading(true)
setAssigneeErrorMessage(null)
try {
const response = await fetchCompanyMembers({ activeOnly: true })
setAssigneeMembers(
response.items.filter((member) =>
member.roles?.some((role) => role === 'ADMIN' || role === 'HR'),
),
)
} catch (error) {
setAssigneeMembers([])
setAssigneeErrorMessage(
error instanceof ApiError ? getErrorMessage(error) : '담당자 목록을 불러오지 못했습니다.',
)
} finally {
setAssigneeMembersLoading(false)
}
}

async function handleChangeAssignee(assigneeId: string) {
if (!task || assigneeSubmitting) return
setAssigneeSubmitting(true)
setAssigneeErrorMessage(null)
try {
const updatedTask = await changeTaskAssignee(task.task_id, {
assignee_id: assigneeId,
expected_version: task.version,
})
setAssigneeOverlayOpen(false)
refetchTask()
refetchActivities()
showToast(`${updatedTask.assignee.display_name}님에게 담당 업무를 변경했습니다.`)
} catch (error) {
setAssigneeErrorMessage(
error instanceof ApiError ? getErrorMessage(error) : '담당자를 변경하지 못했습니다.',
)
} finally {
setAssigneeSubmitting(false)
}
}

function handleExpandContext() {
Expand Down Expand Up @@ -1032,16 +1083,18 @@ export function CaseDetailPage() {
취소
</button>
</li>
<li role="presentation">
<button
type="button"
role="menuitem"
className={styles.moreMenuItem}
onClick={handleReassignCase}
>
담당자 변경
</button>
</li>
{userRole !== 'VIEWER' && (
<li role="presentation">
<button
type="button"
role="menuitem"
className={styles.moreMenuItem}
onClick={handleReassignCase}
>
담당자 변경
</button>
</li>
)}
{canRunRenewal && (
<li role="presentation">
<button
Expand Down Expand Up @@ -1753,6 +1806,18 @@ export function CaseDetailPage() {
onClose={() => setApprovalOverlay('none')}
onSubmit={handleSubmitApprovalRequest}
/>
<AssigneeChangeModal
open={assigneeOverlayOpen}
currentAssigneeId={task.assignee.user_id}
members={assigneeMembers}
loading={assigneeMembersLoading}
submitting={assigneeSubmitting}
errorMessage={assigneeErrorMessage}
onClose={() => {
if (!assigneeSubmitting) setAssigneeOverlayOpen(false)
}}
onSubmit={handleChangeAssignee}
/>
<ApprovalDecisionModal
open={approvalOverlay === 'decision'}
taskTitle={task.title}
Expand Down
Loading