From ab83635b3d42dd4db89e770d1394d5521ded812d Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 18 Aug 2026 18:07:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=97=85=EB=AC=B4=20=EB=8B=B4=EB=8B=B9?= =?UTF-8?q?=EC=9E=90=20=EB=B3=80=EA=B2=BD=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/approvals.test.ts | 41 ++++++-- src/api/tasks.test.ts | 31 ++++++- src/api/tasks.ts | 21 +++++ .../CaseDetailPage/CaseDetailPage.test.tsx | 38 +++++++- src/pages/CaseDetailPage/CaseDetailPage.tsx | 93 ++++++++++++++++--- .../overlays/AssigneeChangeModal.tsx | 80 ++++++++++++++++ .../overlays/overlays.module.css | 32 +++++++ 7 files changed, 310 insertions(+), 26 deletions(-) create mode 100644 src/pages/CaseDetailPage/overlays/AssigneeChangeModal.tsx diff --git a/src/api/approvals.test.ts b/src/api/approvals.test.ts index c6a48c8..6c5ebaa 100644 --- a/src/api/approvals.test.ts +++ b/src/api/approvals.test.ts @@ -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', } } @@ -36,8 +53,14 @@ 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 }, @@ -45,7 +68,9 @@ describe('approval APIs', () => { }) 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 }) diff --git a/src/api/tasks.test.ts b/src/api/tasks.test.ts index ec2c8b2..a1aea31 100644 --- a/src/api/tasks.test.ts +++ b/src/api/tasks.test.ts @@ -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(() => { @@ -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' })) diff --git a/src/api/tasks.ts b/src/api/tasks.ts index ed4981b..6bb2c95 100644 --- a/src/api/tasks.ts +++ b/src/api/tasks.ts @@ -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[] @@ -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 @@ -150,6 +156,21 @@ export function updateTask(taskId: string, body: UpdateTaskBody): Promise { + return apiFetch(`/tasks/${encodeURIComponent(taskId)}/assignee`, { + method: 'PATCH', + body: JSON.stringify(body), + }) +} + export interface UpdateChecklistItemBody { completed: boolean expected_version: number diff --git a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx index d1004d6..3307c9f 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx @@ -56,6 +56,7 @@ function task(overrides: Partial = {}): 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: [], @@ -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( @@ -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() @@ -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 () => { diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx index 5945702..3faf3a3 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx @@ -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, @@ -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, @@ -246,6 +254,11 @@ export function CaseDetailPage() { const [approvalOverlay, setApprovalOverlay] = useState('none') const [completionOverlay, setCompletionOverlay] = useState('none') const [actionPending, setActionPending] = useState(false) + const [assigneeOverlayOpen, setAssigneeOverlayOpen] = useState(false) + const [assigneeMembers, setAssigneeMembers] = useState([]) + const [assigneeMembersLoading, setAssigneeMembersLoading] = useState(false) + const [assigneeSubmitting, setAssigneeSubmitting] = useState(false) + const [assigneeErrorMessage, setAssigneeErrorMessage] = useState(null) const [togglingItemId, setTogglingItemId] = useState(null) const [linkOverlay, setLinkOverlay] = useState('none') const [deliveryConfirmReturn, setDeliveryConfirmReturn] = useState('none') @@ -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() { @@ -1032,16 +1083,18 @@ export function CaseDetailPage() { 취소 -
  • - -
  • + {userRole !== 'VIEWER' && ( +
  • + +
  • + )} {canRunRenewal && (
  • + + + + ) +} diff --git a/src/pages/CaseDetailPage/overlays/overlays.module.css b/src/pages/CaseDetailPage/overlays/overlays.module.css index 0fc2e66..59f13bc 100644 --- a/src/pages/CaseDetailPage/overlays/overlays.module.css +++ b/src/pages/CaseDetailPage/overlays/overlays.module.css @@ -28,7 +28,39 @@ outline-offset: 2px; } +.selectInput { + width: 100%; + box-sizing: border-box; + height: 48px; + padding: 0 15px; + background: var(--surface-default); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-6); + color: var(--text-primary); + font: inherit; +} + +.selectInput:focus-visible { + outline: 2px solid var(--brand-primary); + outline-offset: 2px; +} + +.statusNote, +.errorNote { + margin: var(--fowoco-spacing-12) 0 0; + font-size: 12px; +} + +.statusNote { + color: var(--text-secondary); +} + +.errorNote { + color: var(--status-critical); +} + .fieldLabel { + display: block; margin: 0 0 8px; font-size: 12px; color: var(--text-secondary);