From fe9d808643a6b66655f531e41387cfa3cbf31b13 Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 4 Aug 2026 14:55:08 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor(task):=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=20=EC=83=81=ED=83=9C=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=9D=90=EB=A6=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/approvals.test.ts | 63 ++++ src/api/approvals.ts | 112 ++++++ .../CaseDetailPage/CaseDetailPage.module.css | 4 + .../CaseDetailPage/CaseDetailPage.test.tsx | 135 +++---- src/pages/CaseDetailPage/CaseDetailPage.tsx | 350 +++++++++--------- src/pages/CaseDetailPage/caseDetailData.ts | 73 ---- .../overlays/ApprovalDecisionModal.tsx | 57 +-- .../overlays/ApprovalRequestModal.tsx | 36 +- .../CaseDetailPage/overlays/overlays.test.tsx | 16 +- 9 files changed, 471 insertions(+), 375 deletions(-) create mode 100644 src/api/approvals.test.ts create mode 100644 src/api/approvals.ts 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..b9e8e04 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 () => { @@ -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..0b48486 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' @@ -21,52 +30,40 @@ import { TASK_SOURCE_LABEL, TASK_STATUS_LABEL, TASK_STATUS_TONE } from '../../ut import { daysUntil } from '../../utils/urgency' 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 { @@ -296,6 +300,25 @@ export function CaseDetailPage() { const dueDays = daysUntil(task.due_date) const dueLabel = dueDays === null ? '마감일 없음' : dueDays <= 0 ? '오늘 마감' : `D-${dueDays}` + 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,9 +361,7 @@ export function CaseDetailPage() {

{task.title}

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

@@ -383,65 +404,48 @@ export function CaseDetailPage() {

-

처리 단계

-

필수 단계 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(' · ') || '현재 상태 확인 필요'} +

)} - -
@@ -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/overlays.test.tsx b/src/pages/CaseDetailPage/overlays/overlays.test.tsx index 6d7e7f4..963a802 100644 --- a/src/pages/CaseDetailPage/overlays/overlays.test.tsx +++ b/src/pages/CaseDetailPage/overlays/overlays.test.tsx @@ -13,7 +13,7 @@ describe('ApprovalRequestModal', () => { it('calls onSubmit when the request button is clicked', async () => { const user = userEvent.setup() const onSubmit = vi.fn() - render() + render() await user.click(screen.getByRole('button', { name: '승인 요청 보내기' })) @@ -23,7 +23,7 @@ describe('ApprovalRequestModal', () => { it('calls onClose when cancel is clicked', async () => { const user = userEvent.setup() const onClose = vi.fn() - render() + render() await user.click(screen.getByRole('button', { name: '취소' })) @@ -36,7 +36,17 @@ describe('ApprovalDecisionModal', () => { const user = userEvent.setup() const onApprove = vi.fn() const onReject = vi.fn() - render() + render( + , + ) await user.click(screen.getByRole('button', { name: '반려' })) expect(onReject).toHaveBeenCalledOnce() From 8df216df1d6cc9508ca3e694669b3d1d3e1dd841 Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 4 Aug 2026 15:01:12 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor(document):=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=EC=99=80=20=EC=84=9C=EB=A5=98=20=EC=83=81=ED=83=9C=20ViewModel?= =?UTF-8?q?=20=EC=A0=95=EA=B7=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CaseDetailPage/CaseDetailPage.test.tsx | 2 +- src/pages/CaseDetailPage/CaseDetailPage.tsx | 30 +++--- .../DocumentDetailPage.test.tsx | 11 +-- .../DocumentDetailPage/DocumentDetailPage.tsx | 41 +++------ .../DocumentListPage.test.tsx | 13 ++- .../DocumentListPage/DocumentListPage.tsx | 63 ++++++------- .../WorkListPage/workInboxPresentation.ts | 11 +-- .../WorkerDetailPage.test.tsx | 4 +- .../WorkerDetailPage/WorkerDetailPage.tsx | 41 +++++---- .../WorkerListPage/WorkerListPage.test.tsx | 2 +- src/pages/WorkerListPage/WorkerListPage.tsx | 10 +- src/utils/documentLabels.ts | 19 +--- src/view-models/dateViewModel.test.ts | 27 ++++++ src/view-models/dateViewModel.ts | 83 +++++++++++++++++ src/view-models/documentViewModel.test.ts | 45 +++++++++ src/view-models/documentViewModel.ts | 92 +++++++++++++++++++ 16 files changed, 351 insertions(+), 143 deletions(-) create mode 100644 src/view-models/dateViewModel.test.ts create mode 100644 src/view-models/dateViewModel.ts create mode 100644 src/view-models/documentViewModel.test.ts create mode 100644 src/view-models/documentViewModel.ts diff --git a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx index b9e8e04..469371c 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx @@ -229,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 () => { diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx index 0b48486..cb87110 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx @@ -25,9 +25,9 @@ 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 { AGENT_SUMMARY, @@ -298,8 +298,7 @@ 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 @@ -365,7 +364,7 @@ export function CaseDetailPage() { {TASK_SOURCE_LABEL[task.source]}

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

- + - {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) && ( diff --git a/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx b/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx index 567cd2c..f192cb3 100644 --- a/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx +++ b/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx @@ -84,17 +84,14 @@ describe('DocumentDetailPage', () => { expect(await screen.findByText('서류를 찾을 수 없습니다')).toBeInTheDocument() }) - it('approves and rejects the document locally, toggling status', async () => { - const user = userEvent.setup() + it('does not fabricate approval or rejection without a versioned API', async () => { vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS))) renderPage('D-1') await screen.findByRole('heading', { name: '외국인등록증' }) - await user.click(screen.getByRole('button', { name: '확인 완료 처리' })) - expect(screen.getByText('확인 완료')).toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: '반려' })) - expect(screen.getByText('미제출')).toBeInTheDocument() + expect(screen.getByText('서류 없음')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '반려' })).toBeDisabled() + expect(screen.getByRole('button', { name: '상세 확인' })).toBeDisabled() }) it('shows a loading state', () => { diff --git a/src/pages/DocumentDetailPage/DocumentDetailPage.tsx b/src/pages/DocumentDetailPage/DocumentDetailPage.tsx index 6b62ce9..d599a44 100644 --- a/src/pages/DocumentDetailPage/DocumentDetailPage.tsx +++ b/src/pages/DocumentDetailPage/DocumentDetailPage.tsx @@ -1,27 +1,23 @@ -import { useCallback, useState } from 'react' +import { useCallback } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' -import { fetchDocuments, type SubmissionStatus } from '../../api/documents' +import { fetchDocuments } from '../../api/documents' import { getErrorMessage } from '../../api/errors' import { Button } from '../../components/ui/Button/Button' import { EmptyState } from '../../components/ui/EmptyState/EmptyState' import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel' import { useApiQuery } from '../../hooks/useApiQuery' -import { useToastStore } from '../../store/toastStore' -import { DOCUMENT_TYPE_LABEL, SUBMISSION_STATUS_LABEL, SUBMISSION_STATUS_TONE } from '../../utils/documentLabels' +import { getDocumentViewModel } from '../../view-models/documentViewModel' import styles from './DocumentDetailPage.module.css' export function DocumentDetailPage() { const { documentId } = useParams() const navigate = useNavigate() - const showToast = useToastStore((state) => state.showToast) // GET /api/v1/documents/{id} 단건 조회가 없어서(#57 조사 결과), 목록을 통째로 받아 // worker_document_id로 찾는다. const { status: fetchStatus, data, error, refetch } = useApiQuery(useCallback(() => fetchDocuments({ size: 100 }), [])) const document = data?.items.find((item) => item.worker_document_id === documentId) ?? null - const [localStatus, setLocalStatus] = useState(null) - if (fetchStatus === 'loading') { return (
@@ -57,20 +53,7 @@ export function DocumentDetailPage() { ) } - const status = localStatus ?? document.submission_status - - // TODO(backend): PATCH /api/v1/workers/{workerId}/documents/{id}에는 expected_version이 - // 필요한데, 목록 응답(DocumentItemResponse)에 version 필드가 없어 안전하게 호출할 수 - // 없다 (#57 조사 결과 — 서버에 문의 필요). 그때까지 확인/반려는 화면에서만 반영한다. - function handleApprove() { - setLocalStatus('VERIFIED') - showToast('서류를 확인 완료 처리했습니다.') - } - - function handleReject() { - setLocalStatus('MISSING') - showToast('서류를 반려했습니다. 근로자에게 재제출을 요청하세요.') - } + const view = getDocumentViewModel(document) return (
@@ -81,19 +64,19 @@ export function DocumentDetailPage() {
-

{DOCUMENT_TYPE_LABEL[document.document_type]}

- {SUBMISSION_STATUS_LABEL[status]} +

{view.typeLabel}

+ {view.statusLabel}

- {document.display_name ?? '알 수 없음'} · 만료일 {document.expiry_date ?? '없음'} + {view.workerName} · {view.expiry.display}

첨부 미리보기

{/* TODO(backend): file_id로 실제 파일을 내려받는 API가 아직 없음 */}
-

{DOCUMENT_TYPE_LABEL[document.document_type]}

-

미리보기는 백엔드 연동 후 제공됩니다.

+

{view.typeLabel}

+

{view.fileLabel} · 미리보기 API 연결 전입니다.

@@ -111,11 +94,11 @@ export function DocumentDetailPage() {
- -
diff --git a/src/pages/DocumentListPage/DocumentListPage.test.tsx b/src/pages/DocumentListPage/DocumentListPage.test.tsx index d3b2874..d2b2c38 100644 --- a/src/pages/DocumentListPage/DocumentListPage.test.tsx +++ b/src/pages/DocumentListPage/DocumentListPage.test.tsx @@ -35,6 +35,7 @@ const DOCUMENTS: DocumentItemResponse[] = [ document_type: 'CONTRACT', submission_status: 'SUBMITTED', expiry_date: '2027-07-18', + file_id: 'F-2', }), document({ worker_document_id: 'D-3', @@ -42,6 +43,7 @@ const DOCUMENTS: DocumentItemResponse[] = [ document_type: 'PERMIT', submission_status: 'VERIFIED', expiry_date: '2027-07-10', + file_id: 'F-3', }), document({ worker_document_id: 'D-4', @@ -49,6 +51,7 @@ const DOCUMENTS: DocumentItemResponse[] = [ document_type: 'PASSPORT_COPY', submission_status: 'VERIFIED', expiry_date: isoDateOffset(12), + file_id: 'F-4', }), ] @@ -98,8 +101,8 @@ describe('DocumentListPage', () => { expect(screen.getByRole('tab', { name: '검토 필요 1' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: '만료 예정 1' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: '누락 문서 1' })).toBeInTheDocument() - expect(screen.getByRole('tab', { name: '요청 중 1' })).toBeInTheDocument() - expect(screen.getByRole('tab', { name: '최근 업로드 1' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: '완료 2' })).toBeInTheDocument() + expect(screen.queryByRole('tab', { name: /요청 중/ })).not.toBeInTheDocument() }) it('shows the metric strip computed from document status and expiry', async () => { @@ -154,7 +157,7 @@ describe('DocumentListPage', () => { vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS))) renderPage() - await user.click(await screen.findByRole('button', { name: '확인하기 →' })) + await user.click(await screen.findByRole('button', { name: '검토하기 →' })) expect(await screen.findByText('서류 상세')).toBeInTheDocument() }) @@ -164,8 +167,8 @@ describe('DocumentListPage', () => { renderPage() await screen.findByText('수라즈C') - expect(screen.getByRole('button', { name: '요청 초안' })).toBeInTheDocument() // MISSING - expect(screen.getByRole('button', { name: '확인하기 →' })).toBeInTheDocument() // SUBMITTED + expect(screen.getByRole('button', { name: '상세 확인' })).toBeInTheDocument() // MISSING + expect(screen.getByRole('button', { name: '검토하기 →' })).toBeInTheDocument() // SUBMITTED expect(screen.getAllByRole('button', { name: '보기' })).toHaveLength(2) // VERIFIED }) diff --git a/src/pages/DocumentListPage/DocumentListPage.tsx b/src/pages/DocumentListPage/DocumentListPage.tsx index 0df3806..8354b9e 100644 --- a/src/pages/DocumentListPage/DocumentListPage.tsx +++ b/src/pages/DocumentListPage/DocumentListPage.tsx @@ -10,23 +10,17 @@ import { Tabs } from '../../components/ui/Tabs/Tabs' import { useApiQuery } from '../../hooks/useApiQuery' import { useDebouncedValue } from '../../hooks/useDebouncedValue' import { daysUntil } from '../../utils/urgency' -import { - DOCUMENT_TYPE_LABEL, - getDocumentReviewAction, - SUBMISSION_STATUS_LABEL, - SUBMISSION_STATUS_TONE, -} from '../../utils/documentLabels' +import { DOCUMENT_TYPE_LABEL } from '../../utils/documentLabels' +import { getDocumentViewModel } from '../../view-models/documentViewModel' import styles from './DocumentListPage.module.css' import { FileUploadModal } from './FileUploadModal' -type TabId = 'all' | 'needs-review' | 'expiring-soon' | 'missing' | 'requested' | 'recently-uploaded' +type TabId = 'all' | 'needs-review' | 'expiring-soon' | 'missing' | 'completed' const EXPIRING_SOON_WITHIN_DAYS = 30 -// fowoco/server의 SubmissionStatus는 MISSING/SUBMITTED/VERIFIED 3종뿐이라(#196 조사 결과) -// Figma DOC-001의 6개 탭과 1:1로 대응하지 않는다. "만료 예정"은 expiry_date 기준으로 -// 클라이언트에서 계산하고, "요청 중"·"최근 업로드"는 재요청·업로드 시각 필드가 서버에 없어 -// 각각 MISSING·SUBMITTED로 근사한다. +// 요청 전송 여부와 최근 업로드 시각은 현재 Document API에 없다. MISSING을 "요청 중"으로 +// 추측하지 않고 서버가 보장하는 제출 상태와 expiry_date만 사용한다. function matchesTab(document: DocumentItemResponse, tab: TabId): boolean { if (tab === 'all') return true if (tab === 'needs-review') return document.submission_status === 'SUBMITTED' @@ -35,8 +29,7 @@ function matchesTab(document: DocumentItemResponse, tab: TabId): boolean { return days !== null && days >= 0 && days <= EXPIRING_SOON_WITHIN_DAYS } if (tab === 'missing') return document.submission_status === 'MISSING' - if (tab === 'requested') return document.submission_status === 'MISSING' - return document.submission_status === 'SUBMITTED' + return document.submission_status === 'VERIFIED' } const DOCUMENT_TABS: { id: TabId; label: string }[] = [ @@ -44,8 +37,7 @@ const DOCUMENT_TABS: { id: TabId; label: string }[] = [ { id: 'needs-review', label: '검토 필요' }, { id: 'expiring-soon', label: '만료 예정' }, { id: 'missing', label: '누락 문서' }, - { id: 'requested', label: '요청 중' }, - { id: 'recently-uploaded', label: '최근 업로드' }, + { id: 'completed', label: '완료' }, ] export function DocumentListPage() { @@ -104,7 +96,7 @@ export function DocumentListPage() {

근로자별 서류 제출 현황

- 미제출·확인 대기 서류를 우선 보여주며, 확인이 끝나면 상태가 자동으로 갱신됩니다. + 서류 없음·승인 대기·완료 상태와 문서 만료일을 서버 응답 기준으로 확인합니다.

@@ -183,25 +175,26 @@ export function DocumentListPage() {
) : (
- {visibleDocuments.map((document) => ( - -
-

{document.display_name ?? '알 수 없음'}

-

{DOCUMENT_TYPE_LABEL[document.document_type]}

-
- - {SUBMISSION_STATUS_LABEL[document.submission_status]} - - {document.expiry_date ?? '없음'} - -
- ))} + {visibleDocuments.map((document) => { + const view = getDocumentViewModel(document) + return ( + +
+

{view.workerName}

+

{view.typeLabel}

+
+ {view.statusLabel} + {view.expiry.display} + +
+ ) + })}
)} diff --git a/src/pages/WorkListPage/workInboxPresentation.ts b/src/pages/WorkListPage/workInboxPresentation.ts index a9d5494..bb4b1d6 100644 --- a/src/pages/WorkListPage/workInboxPresentation.ts +++ b/src/pages/WorkListPage/workInboxPresentation.ts @@ -1,7 +1,7 @@ import type { TaskStatus } from '../../api/tasks' import type { StatusTone } from '../../components/ui/StatusLabel/StatusLabel' import { TASK_STATUS_LABEL, TASK_STATUS_TONE, TASK_TYPE_LABEL } from '../../utils/taskStatus' -import { daysUntil } from '../../utils/urgency' +import { getOperationalDateViewModel } from '../../view-models/dateViewModel' import type { WorkInboxTask } from './workInboxModel' const REVIEW_ACTION_LABEL: Record = { @@ -32,13 +32,8 @@ export interface DuePresentation { } export function getDuePresentation(dueDate: string | null): DuePresentation { - const dueDays = daysUntil(dueDate) - if (dueDays === null) return { label: '기한 미정', tone: 'neutral' } - if (dueDays < 0) return { label: `D+${Math.abs(dueDays)}`, tone: 'critical' } - if (dueDays === 0) return { label: '오늘', tone: 'critical' } - if (dueDays <= 7) return { label: `D-${dueDays}`, tone: 'critical' } - if (dueDays <= 30) return { label: `D-${dueDays}`, tone: 'warning' } - return { label: `D-${dueDays}`, tone: 'neutral' } + const due = getOperationalDateViewModel('TASK_DUE', dueDate) + return { label: due.relative ?? '기한 미정', tone: due.tone } } export function getTaskStatusPresentation(status: TaskStatus): { diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx b/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx index 12d1329..37d5965 100644 --- a/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx +++ b/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx @@ -47,7 +47,7 @@ function document(overrides: Partial = {}): DocumentItemRe document_type: 'CONTRACT', submission_status: 'SUBMITTED', expiry_date: '2027-07-18', - file_id: null, + file_id: 'F-1', ...overrides, } } @@ -123,7 +123,7 @@ describe('WorkerDetailPage', () => { renderPage('W-018') expect(await screen.findByText('근로계약서')).toBeInTheDocument() - expect(screen.getByText('확인 대기')).toBeInTheDocument() + expect(screen.getByText('승인 대기')).toBeInTheDocument() }) it('shows an empty state when the worker has no documents', async () => { diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.tsx b/src/pages/WorkerDetailPage/WorkerDetailPage.tsx index 5402d0a..b949e6c 100644 --- a/src/pages/WorkerDetailPage/WorkerDetailPage.tsx +++ b/src/pages/WorkerDetailPage/WorkerDetailPage.tsx @@ -7,8 +7,8 @@ import { DetailRow } from '../../components/ui/DetailRow/DetailRow' import { EmptyState } from '../../components/ui/EmptyState/EmptyState' import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel' import { useApiQuery } from '../../hooks/useApiQuery' -import { DOCUMENT_TYPE_LABEL, SUBMISSION_STATUS_LABEL, SUBMISSION_STATUS_TONE } from '../../utils/documentLabels' -import { daysUntil, getUrgencyTier, URGENCY_TONE } from '../../utils/urgency' +import { getDocumentViewModel } from '../../view-models/documentViewModel' +import { getOperationalDateViewModel } from '../../view-models/dateViewModel' import { RegisterDocumentModal } from './overlays/RegisterDocumentModal' import styles from './WorkerDetailPage.module.css' @@ -56,9 +56,9 @@ export function WorkerDetailPage() { ) } - const deadlineDays = daysUntil(worker.stay_expiry_date) - const deadlineLabel = deadlineDays === null ? '정상' : `D-${deadlineDays} 체류만료` - const deadlineTier = getUrgencyTier(deadlineDays) + const stayExpiry = getOperationalDateViewModel('STAY_EXPIRY', worker.stay_expiry_date) + const contractStart = getOperationalDateViewModel('CONTRACT_START', worker.contract_start_date) + const contractEnd = getOperationalDateViewModel('CONTRACT_END', worker.contract_end_date) return (
@@ -70,8 +70,8 @@ export function WorkerDetailPage() {

{worker.display_name}

- {deadlineTier !== 'comfortable' && ( - {deadlineLabel} + {!stayExpiry.missing && stayExpiry.tone !== 'neutral' && ( + {stayExpiry.relative} 체류만료 )}

@@ -86,10 +86,12 @@ export function WorkerDetailPage() { + +

@@ -107,15 +109,16 @@ export function WorkerDetailPage() { ) : (
- {workerDocuments.map((document) => ( -
- {DOCUMENT_TYPE_LABEL[document.document_type]} - - {SUBMISSION_STATUS_LABEL[document.submission_status]} - - {document.expiry_date ?? '없음'} -
- ))} + {workerDocuments.map((document) => { + const view = getDocumentViewModel(document) + return ( +
+ {view.typeLabel} + {view.statusLabel} + {view.expiry.display} +
+ ) + })}
)}
diff --git a/src/pages/WorkerListPage/WorkerListPage.test.tsx b/src/pages/WorkerListPage/WorkerListPage.test.tsx index 524c8bb..1ea95a3 100644 --- a/src/pages/WorkerListPage/WorkerListPage.test.tsx +++ b/src/pages/WorkerListPage/WorkerListPage.test.tsx @@ -220,7 +220,7 @@ describe('WorkerListPage', () => { .find((el) => el.className.includes(styles.workerDeadline)) expect(urgentRow).toHaveClass(styles.workerDeadlineUrgent) const comfortableRow = screen - .getAllByText('정상') + .getAllByText('체류 만료일 미등록') .find((el) => el.className.includes(styles.workerDeadline)) expect(comfortableRow).toHaveClass(styles.workerDeadlineComfortable) }) diff --git a/src/pages/WorkerListPage/WorkerListPage.tsx b/src/pages/WorkerListPage/WorkerListPage.tsx index a4d3446..168dad0 100644 --- a/src/pages/WorkerListPage/WorkerListPage.tsx +++ b/src/pages/WorkerListPage/WorkerListPage.tsx @@ -16,6 +16,7 @@ import { AUDIT_ACTION_LABEL } from '../../utils/auditLabels' import { formatEventTime } from '../../utils/datetime' import { TASK_STATUS_LABEL, TASK_STATUS_NEXT_ACTION } from '../../utils/taskStatus' import { daysUntil, getUrgencyTier, URGENCY_TONE } from '../../utils/urgency' +import { getOperationalDateViewModel } from '../../view-models/dateViewModel' import styles from './WorkerListPage.module.css' const DEADLINE_TIER_CLASS = { @@ -70,14 +71,11 @@ const PRIORITY_COUNT = 5 // WorkerResponse에는 별도 visa_type 필드가 없다. const VISA_TYPE = 'E-9' -function deadlineLabel(deadlineDays: number | null): string { - if (deadlineDays === null) return '정상' - return `D-${deadlineDays} 체류만료` -} - function toRow(worker: WorkerResponse) { const deadlineDays = daysUntil(worker.stay_expiry_date) - return { worker, deadlineDays, label: deadlineLabel(deadlineDays) } + const expiry = getOperationalDateViewModel('STAY_EXPIRY', worker.stay_expiry_date) + const label = expiry.missing ? expiry.display : `${expiry.relative} 체류만료` + return { worker, deadlineDays, label } } export function WorkerListPage() { diff --git a/src/utils/documentLabels.ts b/src/utils/documentLabels.ts index 59762a3..950da40 100644 --- a/src/utils/documentLabels.ts +++ b/src/utils/documentLabels.ts @@ -1,6 +1,5 @@ import type { StatusTone } from '../components/ui/StatusLabel/StatusLabel' -import type { DocumentItemResponse, DocumentType, SubmissionStatus } from '../api/documents' -import { daysUntil } from './urgency' +import type { DocumentType, SubmissionStatus } from '../api/documents' export const DOCUMENT_TYPE_LABEL: Record = { PASSPORT_COPY: '여권 사본', @@ -10,9 +9,9 @@ export const DOCUMENT_TYPE_LABEL: Record = { } export const SUBMISSION_STATUS_LABEL: Record = { - MISSING: '미제출', - SUBMITTED: '확인 대기', - VERIFIED: '확인 완료', + MISSING: '서류 없음', + SUBMITTED: '승인 대기', + VERIFIED: '완료', } export const SUBMISSION_STATUS_TONE: Record = { @@ -20,13 +19,3 @@ export const SUBMISSION_STATUS_TONE: Record = { SUBMITTED: 'warning', VERIFIED: 'success', } - -// Figma DOC-001(node 1499:1256) 기준 상태별 다음 행동 문구. "증빙 연결"(완료 증빙 유형 전용)은 -// 서버 DocumentType에 대응 값이 없어 별도 처리가 필요해 여기 포함하지 않는다 (#219). -export function getDocumentReviewAction(document: DocumentItemResponse): string { - if (document.submission_status === 'MISSING') return '요청 초안' - const expiryDays = daysUntil(document.expiry_date) - if (expiryDays !== null && expiryDays < 0) return '교체 요청' - if (document.submission_status === 'VERIFIED') return '보기' - return '확인하기 →' -} diff --git a/src/view-models/dateViewModel.test.ts b/src/view-models/dateViewModel.test.ts new file mode 100644 index 0000000..8e91af6 --- /dev/null +++ b/src/view-models/dateViewModel.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' +import { getOperationalDateViewModel } from './dateViewModel' + +describe('getOperationalDateViewModel', () => { + it('keeps the date meaning visible when a value is missing', () => { + expect(getOperationalDateViewModel('STAY_EXPIRY', null)).toMatchObject({ + label: '체류 만료일', + display: '체류 만료일 미등록', + missing: true, + }) + expect(getOperationalDateViewModel('TASK_DUE', null).display).toBe('업무 마감일 미등록') + }) + + it('formats a document expiry date with a relative deadline', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 7, 4, 9)) + + expect(getOperationalDateViewModel('DOCUMENT_EXPIRY', '2026-08-10')).toMatchObject({ + value: '2026.08.10', + relative: 'D-6', + display: '2026.08.10 · D-6', + tone: 'critical', + }) + + vi.useRealTimers() + }) +}) diff --git a/src/view-models/dateViewModel.ts b/src/view-models/dateViewModel.ts new file mode 100644 index 0000000..57c4697 --- /dev/null +++ b/src/view-models/dateViewModel.ts @@ -0,0 +1,83 @@ +import type { StatusTone } from '../components/ui/StatusLabel/StatusLabel' +import { daysUntil } from '../utils/urgency' + +export type OperationalDateKind = + | 'TASK_DUE' + | 'STAY_EXPIRY' + | 'CONTRACT_START' + | 'CONTRACT_END' + | 'DOCUMENT_EXPIRY' + +export interface OperationalDateViewModel { + kind: OperationalDateKind + label: string + value: string + relative: string | null + display: string + tone: StatusTone + missing: boolean + expired: boolean +} + +const DATE_LABEL: Record = { + TASK_DUE: '업무 마감일', + STAY_EXPIRY: '체류 만료일', + CONTRACT_START: '근로계약 시작일', + CONTRACT_END: '근로계약 종료일', + DOCUMENT_EXPIRY: '문서 만료일', +} + +function formatDate(date: string): string { + const [year, month, day] = date.split('-') + if (!year || !month || !day) return date + return `${year}.${month}.${day}` +} + +function getRelativeDate(days: number): string { + if (days < 0) return `D+${Math.abs(days)}` + if (days === 0) return '오늘' + return `D-${days}` +} + +export function getOperationalDateViewModel( + kind: OperationalDateKind, + date: string | null, +): OperationalDateViewModel { + const label = DATE_LABEL[kind] + if (!date) { + return { + kind, + label, + value: '미등록', + relative: null, + display: `${label} 미등록`, + tone: 'neutral', + missing: true, + expired: false, + } + } + + const days = daysUntil(date) + const relative = days === null ? null : getRelativeDate(days) + const isStartDate = kind === 'CONTRACT_START' + const expired = !isStartDate && days !== null && days < 0 + const tone: StatusTone = isStartDate + ? 'neutral' + : days !== null && days <= 7 + ? 'critical' + : days !== null && days <= 30 + ? 'warning' + : 'neutral' + const value = formatDate(date) + + return { + kind, + label, + value, + relative, + display: relative && !isStartDate ? `${value} · ${relative}` : value, + tone, + missing: false, + expired, + } +} diff --git a/src/view-models/documentViewModel.test.ts b/src/view-models/documentViewModel.test.ts new file mode 100644 index 0000000..0340081 --- /dev/null +++ b/src/view-models/documentViewModel.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import type { DocumentItemResponse } from '../api/documents' +import { getDocumentViewModel } from './documentViewModel' + +function document(overrides: Partial = {}): DocumentItemResponse { + return { + worker_document_id: 'D-1', worker_id: 'W-1', display_name: '응웬반A', + document_type: 'PASSPORT_COPY', submission_status: 'MISSING', expiry_date: null, file_id: null, + ...overrides, + } +} + +describe('getDocumentViewModel', () => { + it('does not infer that a missing document was requested', () => { + expect(getDocumentViewModel(document())).toMatchObject({ + workflowState: 'NOT_SUBMITTED', + statusLabel: '서류 없음', + actionLabel: '상세 확인', + fileAvailable: false, + }) + }) + + it('requires a real file before a submitted document can be reviewed', () => { + expect(getDocumentViewModel(document({ submission_status: 'SUBMITTED' }))).toMatchObject({ + statusLabel: '파일 연결 확인', reviewable: false, + }) + expect(getDocumentViewModel(document({ submission_status: 'SUBMITTED', file_id: 'F-1' }))).toMatchObject({ + statusLabel: '승인 대기', actionLabel: '검토하기 →', reviewable: true, + }) + }) + + it('surfaces expiry separately from submission completion', () => { + const expired = new Date() + expired.setDate(expired.getDate() - 1) + const expiryDate = [ + expired.getFullYear(), + String(expired.getMonth() + 1).padStart(2, '0'), + String(expired.getDate()).padStart(2, '0'), + ].join('-') + + expect(getDocumentViewModel(document({ + submission_status: 'VERIFIED', expiry_date: expiryDate, file_id: 'F-1', + }))).toMatchObject({ workflowState: 'EXPIRED', statusLabel: '만료', actionLabel: '교체 요청' }) + }) +}) diff --git a/src/view-models/documentViewModel.ts b/src/view-models/documentViewModel.ts new file mode 100644 index 0000000..f0c6ec7 --- /dev/null +++ b/src/view-models/documentViewModel.ts @@ -0,0 +1,92 @@ +import type { DocumentItemResponse } from '../api/documents' +import type { StatusTone } from '../components/ui/StatusLabel/StatusLabel' +import { DOCUMENT_TYPE_LABEL } from '../utils/documentLabels' +import { getOperationalDateViewModel, type OperationalDateViewModel } from './dateViewModel' + +export type DocumentWorkflowState = 'NOT_SUBMITTED' | 'REVIEW_REQUIRED' | 'COMPLETED' | 'EXPIRED' + +export interface DocumentViewModel { + id: string + workerId: string + workerName: string + typeLabel: string + workflowState: DocumentWorkflowState + statusLabel: string + statusTone: StatusTone + expiry: OperationalDateViewModel + fileAvailable: boolean + fileLabel: string + actionLabel: string + reviewable: boolean +} + +export function getDocumentViewModel(document: DocumentItemResponse): DocumentViewModel { + const expiry = getOperationalDateViewModel('DOCUMENT_EXPIRY', document.expiry_date) + const fileAvailable = Boolean(document.file_id) + + if (document.submission_status === 'MISSING') { + return { + id: document.worker_document_id, + workerId: document.worker_id, + workerName: document.display_name ?? '이름 미등록', + typeLabel: DOCUMENT_TYPE_LABEL[document.document_type], + workflowState: 'NOT_SUBMITTED', + statusLabel: '서류 없음', + statusTone: 'critical', + expiry, + fileAvailable: false, + fileLabel: '파일 없음', + actionLabel: '상세 확인', + reviewable: false, + } + } + + if (expiry.expired) { + return { + id: document.worker_document_id, + workerId: document.worker_id, + workerName: document.display_name ?? '이름 미등록', + typeLabel: DOCUMENT_TYPE_LABEL[document.document_type], + workflowState: 'EXPIRED', + statusLabel: '만료', + statusTone: 'critical', + expiry, + fileAvailable, + fileLabel: fileAvailable ? '파일 연결됨' : '파일 없음', + actionLabel: '교체 요청', + reviewable: false, + } + } + + if (document.submission_status === 'SUBMITTED') { + return { + id: document.worker_document_id, + workerId: document.worker_id, + workerName: document.display_name ?? '이름 미등록', + typeLabel: DOCUMENT_TYPE_LABEL[document.document_type], + workflowState: 'REVIEW_REQUIRED', + statusLabel: fileAvailable ? '승인 대기' : '파일 연결 확인', + statusTone: fileAvailable ? 'warning' : 'critical', + expiry, + fileAvailable, + fileLabel: fileAvailable ? '파일 연결됨' : '파일 없음', + actionLabel: fileAvailable ? '검토하기 →' : '연결 확인', + reviewable: fileAvailable, + } + } + + return { + id: document.worker_document_id, + workerId: document.worker_id, + workerName: document.display_name ?? '이름 미등록', + typeLabel: DOCUMENT_TYPE_LABEL[document.document_type], + workflowState: 'COMPLETED', + statusLabel: '완료', + statusTone: 'success', + expiry, + fileAvailable, + fileLabel: fileAvailable ? '파일 연결됨' : '파일 없음', + actionLabel: fileAvailable ? '보기' : '상세 확인', + reviewable: false, + } +} From dc84c331c1af0a2544f3f4ec3d59d263f7af0835 Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 4 Aug 2026 15:09:05 +0900 Subject: [PATCH 3/7] =?UTF-8?q?chore(task):=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EB=8D=B0=EB=AA=A8=20=EC=98=A4=EB=B2=84?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/CaseDetailPage/caseDetailData.ts | 23 -------- .../overlays/ApprovalSnapshotDiffModal.tsx | 52 ----------------- .../overlays/InternalCompletionModal.tsx | 57 ------------------- .../overlays/OtherApproverHandledModal.tsx | 32 ----------- .../CaseDetailPage/overlays/overlays.test.tsx | 41 ------------- 5 files changed, 205 deletions(-) delete mode 100644 src/pages/CaseDetailPage/overlays/ApprovalSnapshotDiffModal.tsx delete mode 100644 src/pages/CaseDetailPage/overlays/InternalCompletionModal.tsx delete mode 100644 src/pages/CaseDetailPage/overlays/OtherApproverHandledModal.tsx diff --git a/src/pages/CaseDetailPage/caseDetailData.ts b/src/pages/CaseDetailPage/caseDetailData.ts index 2cc2c86..55fad38 100644 --- a/src/pages/CaseDetailPage/caseDetailData.ts +++ b/src/pages/CaseDetailPage/caseDetailData.ts @@ -43,26 +43,3 @@ export const CASE_COMMUNICATION: CaseCommunicationEntry[] = [ { id: 'comm-2', time: '어제 17:40', actor: '김경민', message: '근로자에게 서류 제출 안내 문자를 발송했습니다.' }, { id: 'comm-3', time: '어제 09:05', actor: '응웬반A', message: '서류를 준비 중이라고 답장했습니다.' }, ] - -// 승인 플로우 오버레이 5종 데모 데이터 (Figma "05_States & Overlays" 기준) -export const OTHER_APPROVER_HANDLED = { - policyNote: 'ANY_ONE · 먼저 처리된 결과가 최종입니다.', - rows: [ - { label: '승인 요청일', value: '2026.07.20 10:14' }, - { label: '지정 승인자', value: '김수진 · 박지훈' }, - { label: '처리자', value: '김수진 HR_MANAGER' }, - { label: '처리일', value: '2026.07.20 10:22' }, - { label: '처리 결과', value: '승인됨' }, - { label: '사유', value: '필수서류와 마감일 확인' }, - ], -} - -export const APPROVAL_SNAPSHOT_DIFF = { - warningNote: '승인된 핵심 내용이 변경되어 재승인이 필요합니다.', - rows: [ - { field: '마감일', before: '2026.07.24', after: '2026.07.25', result: '재승인' as const }, - { field: '요청 서류', before: '여권 사본', after: '여권·등록증 사본', result: '재승인' as const }, - { field: '안내문 본문', before: 'V1 승인 문구', after: '마감일 안내 추가', result: '재승인' as const }, - { field: '내부 메모', before: '초안 확인', after: '전화 확인 완료', result: '승인 유지' as const }, - ], -} 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 완료

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