diff --git a/src/api/approvals.test.ts b/src/api/approvals.test.ts new file mode 100644 index 0000000..3f6a801 --- /dev/null +++ b/src/api/approvals.test.ts @@ -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 }) + }) +}) diff --git a/src/api/approvals.ts b/src/api/approvals.ts new file mode 100644 index 0000000..1df9ca2 --- /dev/null +++ b/src/api/approvals.ts @@ -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 | null + hr_snapshot: Record + changed_fields: string[] + source_versions: Record +} + +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 { + return apiFetch(`/tasks/${encodeURIComponent(taskId)}/approval-requests`, { + method: 'POST', + body: JSON.stringify(body), + }) +} + +export function approveTask( + taskId: string, + body: DecideTaskApprovalBody, +): Promise { + return apiFetch(`/tasks/${encodeURIComponent(taskId)}/approve`, { + method: 'POST', + body: JSON.stringify(body), + }) +} + +export function rejectTask( + taskId: string, + body: Required>, +): Promise { + return apiFetch(`/tasks/${encodeURIComponent(taskId)}/reject`, { + method: 'POST', + body: JSON.stringify(body), + }) +} + +export function recordTaskEvidence( + taskId: string, + body: RecordTaskEvidenceBody, +): Promise { + return apiFetch(`/tasks/${encodeURIComponent(taskId)}/evidence`, { + method: 'POST', + body: JSON.stringify(body), + }) +} + +export function completeTask(taskId: string, expectedVersion: number): Promise { + return apiFetch(`/tasks/${encodeURIComponent(taskId)}/complete`, { + method: 'POST', + body: JSON.stringify({ expected_version: expectedVersion }), + }) +} diff --git a/src/pages/CaseDetailPage/CaseDetailPage.module.css b/src/pages/CaseDetailPage/CaseDetailPage.module.css index 21a04aa..fcf0f58 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.module.css +++ b/src/pages/CaseDetailPage/CaseDetailPage.module.css @@ -169,6 +169,10 @@ color: var(--text-secondary); } +.currentStateRows { + margin-bottom: var(--fowoco-spacing-20); +} + .stepList { margin-top: 28px; display: flex; diff --git a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx index c69b6de..469371c 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx @@ -8,7 +8,7 @@ import type { TaskDetailResponse } from '../../api/tasks' import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport' import { useToastStore } from '../../store/toastStore' import { CaseDetailPage } from './CaseDetailPage' -import { CASE_COMMUNICATION, CASE_STEPS, CASE_TABS, CONTEXT_DRAWER } from './caseDetailData' +import { CASE_COMMUNICATION, CASE_TABS, CONTEXT_DRAWER } from './caseDetailData' function jsonResponse(body: unknown, init: ResponseInit = {}) { return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' }, ...init }) @@ -90,6 +90,21 @@ function mockTaskAndActivities( return Promise.resolve(jsonResponse({ draft_id: 'draft-1', version: 1, review_status: 'PENDING' })) } if (url.includes('/documents?')) return Promise.resolve(jsonResponse(documentsResponse(documents))) + if (url.includes('/approval-requests')) { + return Promise.resolve(jsonResponse({ task_id: 'T-1', task_status: 'READY_FOR_REVIEW', task_version: 2 }, { status: 201 })) + } + if (url.endsWith('/approve')) { + return Promise.resolve(jsonResponse({ task_id: 'T-1', task_status: 'APPROVED', task_version: 2 })) + } + if (url.endsWith('/reject')) { + return Promise.resolve(jsonResponse({ task_id: 'T-1', task_status: 'DRAFT', task_version: 2 })) + } + if (url.endsWith('/evidence')) { + return Promise.resolve(jsonResponse({ resource_id: 'E-1', task_id: 'T-1', task_status: 'APPROVED', task_version: 1 }, { status: 201 })) + } + if (url.endsWith('/complete')) { + return Promise.resolve(jsonResponse({ resource_id: 'T-1', task_id: 'T-1', task_status: 'COMPLETED', task_version: 2 })) + } return Promise.resolve(jsonResponse(task(taskOverrides))) }) } @@ -146,15 +161,15 @@ describe('CaseDetailPage', () => { expect(await screen.findByRole('button', { name: '다시 시도' })).toBeInTheDocument() }) - it('renders the real task title/status and every demo agent step', async () => { + it('renders the real Task state without the static five-step demo', async () => { mockTaskAndActivities() renderPage() expect(await screen.findByText('응웬반A 체류연장 준비')).toBeInTheDocument() - expect(screen.getByText('검토 필요')).toBeInTheDocument() - for (const step of CASE_STEPS) { - expect(screen.getByText(step.title)).toBeInTheDocument() - } + expect(screen.getAllByText('검토 필요').length).toBeGreaterThan(0) + expect(screen.getByText('현재 업무 상태')).toBeInTheDocument() + expect(screen.getAllByText('1 / 2').length).toBeGreaterThan(0) + expect(screen.queryByText('보안 링크 전달')).not.toBeInTheDocument() }) it('switches to the checklist tab and toggles a real checklist item', async () => { @@ -214,7 +229,7 @@ describe('CaseDetailPage', () => { await user.click(screen.getByRole('tab', { name: CASE_TABS[2] })) expect(await screen.findByText('여권 사본')).toBeInTheDocument() - expect(screen.getByText('확인 완료')).toBeInTheDocument() + expect(screen.getByText('완료')).toBeInTheDocument() }) it('shows the document-readiness gate and saves a document request draft when documents are missing', async () => { @@ -263,23 +278,17 @@ describe('CaseDetailPage', () => { mockTaskAndActivities() renderPage() - expect(await screen.findByText('완료 처리 불가 · 승인과 증빙 필요')).toBeInTheDocument() + expect(await screen.findByText(/완료 처리 불가 · 승인 · 필수 체크리스트/)).toBeInTheDocument() }) - it('shows a toast when a draft is saved', async () => { + it('requests approval through the API and refetches the Task', async () => { const user = userEvent.setup() - mockTaskAndActivities() - renderPage() - await screen.findByText('응웬반A 체류연장 준비') - - await user.click(screen.getByRole('button', { name: '초안 저장' })) - - expect(screen.getByText('초안을 저장했습니다.')).toBeInTheDocument() - }) - - it('opens the approval request modal and shows a toast on submit', async () => { - const user = userEvent.setup() - mockTaskAndActivities() + mockTaskAndActivities({ + status: 'DRAFT', + checklist_items: [ + { checklist_item_id: 'chk-1', item_code: 'passport', label: '여권 사본 확인', required: true, completed: true, completed_by: null, completed_at: null, version: 1 }, + ], + }) renderPage() await screen.findByText('응웬반A 체류연장 준비') @@ -290,30 +299,33 @@ describe('CaseDetailPage', () => { expect(screen.getByText('승인을 요청했습니다.')).toBeInTheDocument() expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + const call = vi.mocked(fetch).mock.calls.find(([url]) => String(url).includes('/approval-requests')) + expect(call?.[1]?.method).toBe('POST') }) - it('walks through the approve decision flow', async () => { + it('approves through the API instead of setting a local success state', async () => { const user = userEvent.setup() mockTaskAndActivities() renderPage() await screen.findByText('응웬반A 체류연장 준비') - await user.click(screen.getByRole('button', { name: '데모: 승인자로 검토' })) + await user.click(screen.getByRole('button', { name: '승인 검토' })) expect(screen.getByRole('dialog', { name: '승인 요청을 검토하세요' })).toBeInTheDocument() await user.click(screen.getByRole('button', { name: '승인' })) expect(screen.getByText('승인했습니다.')).toBeInTheDocument() - expect(screen.getAllByText('승인 완료').length).toBeGreaterThan(0) + expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/approve'))).toBe(true) + expect(screen.queryByText('승인 완료')).not.toBeInTheDocument() }) - it('walks through the reject decision flow', async () => { + it('rejects through the API without fabricating a local rejected status', async () => { const user = userEvent.setup() mockTaskAndActivities() renderPage() await screen.findByText('응웬반A 체류연장 준비') - await user.click(screen.getByRole('button', { name: '데모: 승인자로 검토' })) + await user.click(screen.getByRole('button', { name: '승인 검토' })) await user.click(screen.getByRole('button', { name: '반려' })) expect(screen.getByRole('dialog', { name: '반려 사유를 입력하세요' })).toBeInTheDocument() @@ -321,36 +333,8 @@ describe('CaseDetailPage', () => { await user.click(screen.getByRole('button', { name: '반려 확정' })) expect(screen.getByText('반려했습니다.')).toBeInTheDocument() - expect(screen.getAllByText('반려됨').length).toBeGreaterThan(0) - }) - - it('shows the other-approver-handled overlay after a decision is already made', async () => { - const user = userEvent.setup() - mockTaskAndActivities() - renderPage() - await screen.findByText('응웬반A 체류연장 준비') - - await user.click(screen.getByRole('button', { name: '데모: 승인자로 검토' })) - await user.click(screen.getByRole('button', { name: '승인' })) - - await user.click(screen.getByRole('button', { name: '데모: 승인자로 검토' })) - - expect(screen.getByRole('dialog', { name: '다른 승인자가 처리했습니다' })).toBeInTheDocument() - }) - - it('opens the snapshot diff overlay and re-requests approval', async () => { - const user = userEvent.setup() - mockTaskAndActivities() - renderPage() - await screen.findByText('응웬반A 체류연장 준비') - - await user.click(screen.getByRole('button', { name: '데모: 재승인 필요 보기' })) - expect(screen.getByRole('dialog', { name: '승인본 V1 · 수정본 V2 변경 내용' })).toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: '재승인 요청' })) - - expect(screen.getByText('재승인을 요청했습니다.')).toBeInTheDocument() - expect(screen.getAllByText('승인 대기').length).toBeGreaterThan(0) + expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/reject'))).toBe(true) + expect(screen.queryByText('반려됨')).not.toBeInTheDocument() }) it('opens and closes the more menu', async () => { @@ -445,50 +429,37 @@ describe('CaseDetailPage', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) - it('blocks completion until approved, then completes via the external completion overlay', async () => { + it('records evidence and completes through the API when the server Task is approved', async () => { const user = userEvent.setup() - mockTaskAndActivities() + mockTaskAndActivities({ + status: 'APPROVED', + checklist_items: [ + { checklist_item_id: 'chk-1', item_code: 'passport', label: '여권 사본 확인', required: true, completed: true, completed_by: null, completed_at: null, version: 1 }, + ], + }) renderPage() await screen.findByText('응웬반A 체류연장 준비') - expect(screen.queryByRole('button', { name: '완료 처리 시작 →' })).not.toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: '데모: 승인자로 검토' })) - await user.click(screen.getByRole('button', { name: '승인' })) - - await user.click(screen.getByRole('button', { name: '완료 처리 시작 →' })) + await user.click(await screen.findByRole('button', { name: '완료 처리 시작 →' })) expect(screen.getByRole('dialog', { name: '외부기관 업무 완료' })).toBeInTheDocument() await user.click(screen.getByRole('button', { name: '접수번호' })) await user.type(screen.getByPlaceholderText('접수번호를 입력하세요'), 'HI-2026-0718-032') await user.click(screen.getByLabelText('실제 제출은 담당자가 직접 수행했습니다.')) - await user.click(screen.getByRole('button', { name: '완료 처리' })) - - expect(screen.getByText('완료 처리했습니다.')).toBeInTheDocument() - expect(screen.getByText('완료 처리되었습니다.')).toBeInTheDocument() - }) - - it('opens the internal completion demo overlay independent of approval state', async () => { - const user = userEvent.setup() - mockTaskAndActivities() - renderPage() - await screen.findByText('응웬반A 체류연장 준비') + await user.click(within(screen.getByRole('dialog', { name: '외부기관 업무 완료' })).getByRole('button', { name: '완료 처리' })) - await user.click(screen.getByRole('button', { name: '데모: 내부업무 완료 보기' })) - expect(screen.getByRole('dialog', { name: '일반 내부업무 완료' })).toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: '파일 없이 완료' })) - - expect(screen.getByText('(데모) 내부업무를 완료 처리했습니다.')).toBeInTheDocument() + expect(screen.getByText('업무를 완료했습니다.')).toBeInTheDocument() + expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/evidence'))).toBe(true) + expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/complete'))).toBe(true) }) it('reissues the security link and shows the new-link overlay', async () => { const user = userEvent.setup() - mockTaskAndActivities() + mockTaskAndActivities({ status: 'APPROVED' }) renderPage() await screen.findByText('응웬반A 체류연장 준비') - await user.click(screen.getByRole('button', { name: '보안 링크 재발급 →' })) + await user.click(screen.getByRole('button', { name: '근로자 보안 링크 발급·재발급 →' })) const reissueDialog = screen.getByRole('dialog', { name: '보안 링크 재발급' }) expect(within(reissueDialog).getByText('응웬반A 체류연장 준비')).toBeInTheDocument() diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx index 08872a4..cb87110 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx @@ -1,5 +1,14 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Link, useParams } from 'react-router-dom' +import { + approveTask, + buildTaskApprovalSnapshot, + completeTask, + recordTaskEvidence, + rejectTask, + requestTaskApproval, + type EvidenceType, +} from '../../api/approvals' import { fetchTaskActivities } from '../../api/audit' import { fetchDocumentReadiness, fetchDocuments, upsertDocumentRequestDraft } from '../../api/documents' import { ApiError, getErrorMessage } from '../../api/errors' @@ -16,57 +25,45 @@ import { useApiQuery } from '../../hooks/useApiQuery' import { useToastStore } from '../../store/toastStore' import { ACTOR_TYPE_TO_AGENT_SOURCE, AUDIT_ACTION_LABEL } from '../../utils/auditLabels' import { formatEventTime } from '../../utils/datetime' -import { DOCUMENT_TYPE_LABEL, SUBMISSION_STATUS_LABEL, SUBMISSION_STATUS_TONE } from '../../utils/documentLabels' import { TASK_SOURCE_LABEL, TASK_STATUS_LABEL, TASK_STATUS_TONE } from '../../utils/taskStatus' -import { daysUntil } from '../../utils/urgency' +import { getDocumentViewModel } from '../../view-models/documentViewModel' +import { getOperationalDateViewModel } from '../../view-models/dateViewModel' import styles from './CaseDetailPage.module.css' import { - ACTION_DOCK, AGENT_SUMMARY, CASE_COMMUNICATION, - CASE_STEPS, CASE_TABS, - COMPLETION_GATES, CONTEXT_ACCESS, CONTEXT_DRAWER, - type StepStatus, } from './caseDetailData' import { ApprovalDecisionModal } from './overlays/ApprovalDecisionModal' import { ApprovalRequestModal } from './overlays/ApprovalRequestModal' -import { ApprovalSnapshotDiffModal } from './overlays/ApprovalSnapshotDiffModal' import { ExternalCompletionModal } from './overlays/ExternalCompletionModal' -import { InternalCompletionModal } from './overlays/InternalCompletionModal' import { LinkReissueModal, type ReissueSubmission } from './overlays/LinkReissueModal' import { LinkReissuedModal } from './overlays/LinkReissuedModal' -import { OtherApproverHandledModal } from './overlays/OtherApproverHandledModal' import { RejectionReasonModal } from './overlays/RejectionReasonModal' -type ApprovalOverlay = 'none' | 'request' | 'decision' | 'rejection' | 'other-handled' | 'snapshot-diff' -type ApprovalState = 'pending' | 'approved' | 'rejected' -type CompletionOverlay = 'none' | 'external' | 'internal-demo' -type CompletionState = 'blocked' | 'completed' +type ApprovalOverlay = 'none' | 'request' | 'decision' | 'rejection' +type CompletionOverlay = 'none' | 'external' type LinkOverlay = 'none' | 'reissue' | 'reissued' -const APPROVAL_BADGE: Record = { - pending: { label: '승인 대기', tone: 'warning' }, - approved: { label: '승인 완료', tone: 'success' }, - rejected: { label: '반려됨', tone: 'critical' }, -} - const CASE_TAB_ITEMS = CASE_TABS.map((label) => ({ id: label, label })) -const STEP_CIRCLE_CLASS: Record = { - done: styles.stepCircleDone, - pending: styles.stepCirclePending, - locked: styles.stepCircleLocked, - waiting: styles.stepCircleWaiting, +function getApprovalBadge(status: import('../../api/tasks').TaskStatus): { + label: string + tone: StatusTone +} | null { + if (status === 'READY_FOR_REVIEW') return { label: '승인 대기', tone: 'warning' } + if (status === 'APPROVED' || status === 'WAITING_WORKER' || status === 'WAITING_EXTERNAL') { + return { label: '승인 완료', tone: 'success' } + } + return null } -const STEP_STATUS_CLASS: Record = { - done: styles.stepStatusDone, - pending: styles.stepStatusPending, - locked: styles.stepStatusLocked, - waiting: styles.stepStatusWaiting, +const EVIDENCE_TYPE_BY_LABEL: Record = { + 접수번호: 'RECEIPT', + 파일: 'DOCUMENT', + '화면 캡처': 'OFFICIAL_RESULT', } export function CaseDetailPage() { @@ -75,9 +72,8 @@ export function CaseDetailPage() { const [moreMenuOpen, setMoreMenuOpen] = useState(false) const [contextDrawerOpen, setContextDrawerOpen] = useState(false) const [approvalOverlay, setApprovalOverlay] = useState('none') - const [approvalState, setApprovalState] = useState('pending') const [completionOverlay, setCompletionOverlay] = useState('none') - const [completionState, setCompletionState] = useState('blocked') + const [actionPending, setActionPending] = useState(false) const [togglingItemId, setTogglingItemId] = useState(null) const [linkOverlay, setLinkOverlay] = useState('none') const [lastReissue, setLastReissue] = useState(null) @@ -127,71 +123,84 @@ export function CaseDetailPage() { setApprovalOverlay('request') } - function handleSubmitApprovalRequest() { - // TODO(backend): POST /api/work-items/:id/approval-request -> 승인 대기 상태로 전환 - setApprovalOverlay('none') - showToast('승인을 요청했습니다.') + async function handleSubmitApprovalRequest() { + if (!task || actionPending) return + setActionPending(true) + try { + await requestTaskApproval(task.task_id, buildTaskApprovalSnapshot(task)) + setApprovalOverlay('none') + refetchTask() + showToast('승인을 요청했습니다.') + } catch (error) { + showToast(error instanceof ApiError ? getErrorMessage(error) : '승인을 요청하지 못했습니다.') + } finally { + setActionPending(false) + } } function handleOpenReview() { - // 데모 진입점: 실제로는 승인자 계정으로 로그인해야 볼 수 있는 화면이다. - setApprovalOverlay(approvalState === 'pending' ? 'decision' : 'other-handled') + if (task?.status !== 'READY_FOR_REVIEW') return + setApprovalOverlay('decision') } - function handleApprove() { - // TODO(backend): POST /api/work-items/:id/approval-decisions { decision: 'approved' } - setApprovalState('approved') - setApprovalOverlay('none') - showToast('승인했습니다.') + async function handleApprove() { + if (!task || actionPending) return + setActionPending(true) + try { + await approveTask(task.task_id, { expected_version: task.version }) + setApprovalOverlay('none') + refetchTask() + showToast('승인했습니다.') + } catch (error) { + showToast(error instanceof ApiError ? getErrorMessage(error) : '승인하지 못했습니다.') + } finally { + setActionPending(false) + } } function handleStartReject() { setApprovalOverlay('rejection') } - function handleConfirmReject(reason: string) { - // TODO(backend): POST /api/work-items/:id/approval-decisions { decision: 'rejected', reason } - void reason - setApprovalState('rejected') - setApprovalOverlay('none') - showToast('반려했습니다.') - } - - function handleOpenSnapshotDiff() { - setApprovalOverlay('snapshot-diff') - } - - function handleRequestReapproval() { - // TODO(backend): POST /api/work-items/:id/approval-request -> 재승인 요청, 승인 대기 상태로 전환 - setApprovalState('pending') - setApprovalOverlay('none') - showToast('재승인을 요청했습니다.') + async function handleConfirmReject(reason: string) { + if (!task || actionPending) return + setActionPending(true) + try { + await rejectTask(task.task_id, { expected_version: task.version, reason }) + setApprovalOverlay('none') + refetchTask() + showToast('반려했습니다.') + } catch (error) { + showToast(error instanceof ApiError ? getErrorMessage(error) : '반려하지 못했습니다.') + } finally { + setActionPending(false) + } } function handleOpenExternalCompletion() { - if (approvalState !== 'approved' || completionState === 'completed') return + if (!task || !['APPROVED', 'WAITING_WORKER', 'WAITING_EXTERNAL'].includes(task.status)) return setCompletionOverlay('external') } - function handleCompleteExternal(evidenceType: string, evidenceValue: string, memo: string) { - // TODO(backend): POST /api/work-items/:id/complete { evidenceType, evidenceValue, memo } - void evidenceType - void evidenceValue - void memo - setCompletionState('completed') - setCompletionOverlay('none') - showToast('완료 처리했습니다.') - } - - function handleOpenInternalCompletionDemo() { - setCompletionOverlay('internal-demo') - } - - function handleCompleteInternalDemo(memo: string) { - // 이 데모 케이스는 외부기관 유형이라 실제 완료 상태에는 반영하지 않는다. - void memo - setCompletionOverlay('none') - showToast('(데모) 내부업무를 완료 처리했습니다.') + async function handleCompleteExternal(evidenceType: string, evidenceValue: string, memo: string) { + if (!task || actionPending) return + const normalizedEvidenceType = EVIDENCE_TYPE_BY_LABEL[evidenceType] + if (!normalizedEvidenceType) return + setActionPending(true) + try { + const evidence = await recordTaskEvidence(task.task_id, { + evidence_type: normalizedEvidenceType, + note: [evidenceValue.trim(), memo.trim()].filter(Boolean).join(' · '), + }) + await completeTask(task.task_id, evidence.task_version) + setCompletionOverlay('none') + refetchTask() + showToast('업무를 완료했습니다.') + } catch (error) { + showToast(error instanceof ApiError ? getErrorMessage(error) : '업무를 완료하지 못했습니다.') + } finally { + setActionPending(false) + } } function handleMoreActions() { @@ -238,11 +247,6 @@ export function CaseDetailPage() { setContextDrawerOpen(true) } - function handleSaveDraft() { - // TODO(backend): PATCH /api/work-items/:id/draft -> 현재 입력 상태 저장 - showToast('초안을 저장했습니다.') - } - async function handleSaveDocumentRequestDraft() { if (!task || !readiness) return try { @@ -294,8 +298,26 @@ export function CaseDetailPage() { ) } - const dueDays = daysUntil(task.due_date) - const dueLabel = dueDays === null ? '마감일 없음' : dueDays <= 0 ? '오늘 마감' : `D-${dueDays}` + const taskDue = getOperationalDateViewModel('TASK_DUE', task.due_date) + const approvalBadge = getApprovalBadge(task.status) + const requiredChecklist = task.checklist_items.filter((item) => item.required) + const completedRequiredChecklist = requiredChecklist.filter((item) => item.completed).length + const checklistReady = completedRequiredChecklist === requiredChecklist.length + const informationReady = task.missing_required_slots.length === 0 + const documentsReady = readiness ? !readiness.completion_blocked : false + const approvalReady = task.status === 'APPROVED' || task.status === 'WAITING_WORKER' || task.status === 'WAITING_EXTERNAL' + const canRequestApproval = + (task.status === 'DRAFT' || task.status === 'NEEDS_INFO') && + checklistReady && + informationReady && + documentsReady + const canComplete = approvalReady && checklistReady && informationReady && documentsReady + const completionBlockers = [ + !approvalReady && '승인', + !checklistReady && '필수 체크리스트', + !informationReady && '필수 정보', + !documentsReady && '서류 준비', + ].filter(Boolean) as string[] return (
@@ -338,13 +360,11 @@ export function CaseDetailPage() {

{task.title}

{TASK_STATUS_LABEL[task.status]} - - {APPROVAL_BADGE[approvalState].label} - + {approvalBadge && {approvalBadge.label}} {TASK_SOURCE_LABEL[task.source]}

- {dueLabel} · {task.workflow_id} + {taskDue.display} · {task.workflow_id}

-

처리 단계

-

필수 단계 3 / 5 완료

+

현재 업무 상태

+ + {TASK_STATUS_LABEL[task.status]} +
- -
- {CASE_STEPS.map((step, index) => ( -
-
- - {step.status === 'done' ? '✓' : step.no} - - {index < CASE_STEPS.length - 1 && ( - - )} -
-
-
-

{step.title}

-

{step.actor}

- {step.title === '보안 링크 전달' && ( - - )} -
- - {step.statusLabel} - -
-
- ))} +

+ 서버에 저장된 현재 Task와 체크리스트만 표시합니다. 고정된 예시 단계는 사용하지 않습니다. +

+
+ + + +
+ {approvalReady && ( + + )}

완료 조건

-

{COMPLETION_GATES.description}

+

현재 서버 상태와 필수 조건을 기준으로 확인합니다.

- - - {approvalState === 'approved' && completionState === 'blocked' ? ( + {canComplete ? ( - ) : completionState === 'completed' ? ( + ) : task.status === 'COMPLETED' ? (

완료 처리되었습니다.

) : ( -

{COMPLETION_GATES.blocked}

+

+ 완료 처리 불가 · {completionBlockers.join(' · ') || '현재 상태 확인 필요'} +

)} - -
@@ -541,15 +532,16 @@ export function CaseDetailPage() { )} {documentsStatus === 'success' && (
- {documents.map((document) => ( -
- {DOCUMENT_TYPE_LABEL[document.document_type]} - - {SUBMISSION_STATUS_LABEL[document.submission_status]} - - {document.expiry_date ?? '없음'} -
- ))} + {documents.map((document) => { + const view = getDocumentViewModel(document) + return ( +
+ {view.typeLabel} + {view.statusLabel} + {view.expiry.display} +
+ ) + })}
)} {readiness && (readiness.missing.length > 0 || readiness.expired.length > 0) && ( @@ -599,28 +591,48 @@ export function CaseDetailPage() { )}
- {ACTION_DOCK.nextStep} - - - - + + {task.status === 'READY_FOR_REVIEW' + ? '다음 행동 · 승인 검토' + : task.status === 'COMPLETED' + ? '이 업무는 완료되었습니다.' + : task.status === 'CANCELLED' + ? '이 업무는 취소되었습니다.' + : approvalReady + ? '다음 행동 · 실행 결과와 증빙 확인' + : '다음 행동 · 필수 조건 확인 후 승인 요청'} + + {task.status === 'READY_FOR_REVIEW' && ( + + )} + {(task.status === 'DRAFT' || task.status === 'NEEDS_INFO') && ( + + )} + {canComplete && ( + + )}
-

{ACTION_DOCK.footnote}

+

+ 승인·반려·완료 결과는 서버 응답 후 Task를 다시 조회해 반영합니다. 화면에서 성공 상태를 임의로 만들지 않습니다. +

setApprovalOverlay('none')} onSubmit={handleSubmitApprovalRequest} /> setApprovalOverlay('none')} onApprove={handleApprove} onReject={handleStartReject} @@ -630,25 +642,11 @@ export function CaseDetailPage() { onBack={() => setApprovalOverlay('decision')} onConfirm={handleConfirmReject} /> - setApprovalOverlay('none')} - /> - setApprovalOverlay('none')} - onRequestReapproval={handleRequestReapproval} - /> setCompletionOverlay('none')} onComplete={handleCompleteExternal} /> - setCompletionOverlay('none')} - onComplete={handleCompleteInternalDemo} - /> void onApprove: () => void onReject: () => void } -export function ApprovalDecisionModal({ open, onClose, onApprove, onReject }: ApprovalDecisionModalProps) { - function handleEditThenApprove() { - // TODO(backend): 승인본 내용을 수정한 뒤 승인하는 흐름. PATCH API 계약이 정해지면 구현한다. - } - +export function ApprovalDecisionModal({ + open, + taskTitle, + dueDate, + workflowId, + submitting = false, + onClose, + onApprove, + onReject, +}: ApprovalDecisionModalProps) { return ( -

- 요청자 {APPROVAL_SNAPSHOT.requester} · {APPROVAL_SNAPSHOT.requestedAt} · 승인 대기 -

+

서버에 저장된 현재 Task 버전을 검토합니다.

-

승인본 V1 · 핵심 내용 Snapshot

- {APPROVAL_SNAPSHOT.rows.map((row) => ( -
- {row.label} - {row.value} -
- ))} +

현재 승인 대상

+
+ 업무 + {taskTitle} +
+
+ 마감일 + {dueDate ?? '미지정'} +
+
+ Workflow + {workflowId} +
-

{APPROVAL_SNAPSHOT.diffNote} ▾

-
-

{APPROVAL_SNAPSHOT.decisionPolicy}

+

승인·반려 결과는 서버 활동이력에 기록됩니다.

- - -
diff --git a/src/pages/CaseDetailPage/overlays/ApprovalRequestModal.tsx b/src/pages/CaseDetailPage/overlays/ApprovalRequestModal.tsx index 3757a24..abaed4b 100644 --- a/src/pages/CaseDetailPage/overlays/ApprovalRequestModal.tsx +++ b/src/pages/CaseDetailPage/overlays/ApprovalRequestModal.tsx @@ -1,50 +1,54 @@ import { Modal } from '../../../components/ui/Modal/Modal' -import { APPROVAL_REQUEST_FORM } from '../caseDetailData' import styles from './overlays.module.css' export interface ApprovalRequestModalProps { open: boolean + taskTitle: string + dueDate: string | null + submitting?: boolean onClose: () => void onSubmit: () => void } -export function ApprovalRequestModal({ open, onClose, onSubmit }: ApprovalRequestModalProps) { +export function ApprovalRequestModal({ + open, + taskTitle, + dueDate, + submitting = false, + onClose, + onSubmit, +}: ApprovalRequestModalProps) { return (

- 안내문과 핵심 내용을 지정 승인자 또는 승인자 그룹에 요청합니다. + 현재 Task의 제목·마감일·업무 데이터와 버전을 승인본으로 고정합니다.

승인 대상

-
{APPROVAL_REQUEST_FORM.target}
+
{taskTitle}
-

승인자

-
{APPROVAL_REQUEST_FORM.approverGroup}
+

업무 마감일

+
{dueDate ?? '미지정'}
-

{APPROVAL_REQUEST_FORM.anyOneRuleTitle}

-

{APPROVAL_REQUEST_FORM.anyOneRuleBody}

-
- -
-

요청 메모

-
{APPROVAL_REQUEST_FORM.memo}
+

현재 버전만 승인됩니다.

+

승인 후 핵심 내용이 바뀌면 기존 승인은 무효화되고 다시 검토해야 합니다.

-
-

{APPROVAL_REQUEST_FORM.footnote}

+

외부 발송이 아니라 FOWOCO 내부 승인 요청입니다.

) } diff --git a/src/pages/CaseDetailPage/overlays/ApprovalSnapshotDiffModal.tsx b/src/pages/CaseDetailPage/overlays/ApprovalSnapshotDiffModal.tsx deleted file mode 100644 index 739689e..0000000 --- a/src/pages/CaseDetailPage/overlays/ApprovalSnapshotDiffModal.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Modal } from '../../../components/ui/Modal/Modal' -import { APPROVAL_SNAPSHOT_DIFF } from '../caseDetailData' -import styles from './overlays.module.css' - -export interface ApprovalSnapshotDiffModalProps { - open: boolean - onClose: () => void - onRequestReapproval: () => void -} - -export function ApprovalSnapshotDiffModal({ - open, - onClose, - onRequestReapproval, -}: ApprovalSnapshotDiffModalProps) { - return ( - -

{APPROVAL_SNAPSHOT_DIFF.warningNote}

- -
-
- 변경 필드 - 승인본 V1 - 수정본 V2 - 결과 -
- {APPROVAL_SNAPSHOT_DIFF.rows.map((row, index) => ( -
- {row.field} - {row.before} - {row.after} - - {row.result} - -
- ))} -
- -
- - -
-
- ) -} diff --git a/src/pages/CaseDetailPage/overlays/InternalCompletionModal.tsx b/src/pages/CaseDetailPage/overlays/InternalCompletionModal.tsx deleted file mode 100644 index 4fbaaa2..0000000 --- a/src/pages/CaseDetailPage/overlays/InternalCompletionModal.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useState } from 'react' -import { Modal } from '../../../components/ui/Modal/Modal' -import styles from './overlays.module.css' - -export interface InternalCompletionModalProps { - open: boolean - onClose: () => void - onComplete: (memo: string) => void -} - -export function InternalCompletionModal({ open, onClose, onComplete }: InternalCompletionModalProps) { - const [memo, setMemo] = useState('') - - function handleComplete() { - onComplete(memo) - setMemo('') - } - - return ( - -

- 필수 체크리스트가 완료되어 파일 첨부 없이 완료할 수 있습니다. -

- -

✓ 필수 체크리스트 4 / 4 완료

- -
- 증빙 요구 - 증빙 불필요 -
-
- 완료 처리자 - 김민지 · 자동 기록 -
-
- 완료 일시 - 완료 시점 자동 기록 -
- -