diff --git a/src/api/documents.test.ts b/src/api/documents.test.ts index 53aaacb..2d2e2d7 100644 --- a/src/api/documents.test.ts +++ b/src/api/documents.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { fetchDocuments, patchWorkerDocument, registerWorkerDocument } from './documents' +import { + fetchDocumentRequestDraft, + fetchDocuments, + patchWorkerDocument, + registerWorkerDocument, + upsertDocumentRequestDraft, +} from './documents' function jsonResponse(body: unknown) { return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }) @@ -87,3 +93,55 @@ describe('patchWorkerDocument', () => { expect(init?.method).toBe('PATCH') }) }) + +describe('document request draft', () => { + it('GETs the saved content and version for recovery', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + jsonResponse({ + draft_id: 'draft-1', + language: 'vi', + document_types: ['PASSPORT_COPY', 'CONTRACT'], + message: 'Vui lòng nộp hồ sơ.', + version: 3, + review_status: 'DRAFT', + updated_at: '2026-08-08T00:00:00Z', + }), + ) + + const draft = await fetchDocumentRequestDraft('T/1') + + expect(draft.version).toBe(3) + const [url, init] = vi.mocked(fetch).mock.calls[0] + expect(url).toContain('/tasks/T%2F1/document-request-draft') + expect(init?.method).toBeUndefined() + }) + + it('PUTs the restored expected version with the edited message', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + jsonResponse({ + draft_id: 'draft-1', + language: 'ko', + document_types: ['ARC'], + message: '외국인등록증을 제출해 주세요.', + version: 2, + review_status: 'DRAFT', + updated_at: '2026-08-08T00:00:00Z', + }), + ) + + await upsertDocumentRequestDraft('T/1', { + language: 'ko', + document_types: ['ARC'], + message: '외국인등록증을 제출해 주세요.', + expected_version: 1, + }) + + const [url, init] = vi.mocked(fetch).mock.calls[0] + expect(url).toContain('/tasks/T%2F1/document-request-draft') + expect(init?.method).toBe('PUT') + expect(JSON.parse(String(init?.body))).toMatchObject({ + message: '외국인등록증을 제출해 주세요.', + expected_version: 1, + }) + }) +}) diff --git a/src/api/documents.ts b/src/api/documents.ts index ab7db38..6a52985 100644 --- a/src/api/documents.ts +++ b/src/api/documents.ts @@ -65,12 +65,22 @@ export interface DocumentRequestUpsertBody { export interface DocumentRequestDraftResponse { draft_id: string + language: string + document_types: DocumentType[] + message: string | null version: number review_status: string + updated_at: string +} + +export function fetchDocumentRequestDraft(taskId: string): Promise { + return apiFetch( + `/tasks/${encodeURIComponent(taskId)}/document-request-draft`, + ) } -// 초안 저장만 하고 실제 발송·Worker Link 생성은 하지 않는다 (#176 스코프 아님). -// 최초 생성 시 expected_version은 관례상 0을 보낸다. +// 초안 저장만 하고 실제 발송·Worker Link 생성은 하지 않는다. 최초 생성 시 +// expected_version은 0, 이후에는 fetchDocumentRequestDraft가 반환한 최신 version을 보낸다. export function upsertDocumentRequestDraft( taskId: string, body: DocumentRequestUpsertBody, diff --git a/src/api/workerLinks.ts b/src/api/workerLinks.ts index b4e5fde..0f5802f 100644 --- a/src/api/workerLinks.ts +++ b/src/api/workerLinks.ts @@ -1,4 +1,5 @@ import { apiFetch } from './client' +import type { DocumentType } from './documents' export type WorkerResponseType = | 'ACKNOWLEDGED' @@ -19,7 +20,9 @@ export interface WorkerLinkIssueResponse { export interface WorkerLinkViewResponse { guidance: string + language: string due_date: string | null + requested_document_types: DocumentType[] allowed_responses: WorkerResponseType[] } diff --git a/src/pages/CaseDetailPage/CaseDetailPage.module.css b/src/pages/CaseDetailPage/CaseDetailPage.module.css index 2ae76e7..4c59ee0 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.module.css +++ b/src/pages/CaseDetailPage/CaseDetailPage.module.css @@ -406,6 +406,143 @@ color: var(--text-secondary); } +.documentRequestStatus { + margin: 16px 0 0; + color: var(--text-secondary); + font-size: 13px; +} + +.documentRequestError { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 16px; + padding: var(--fowoco-spacing-12) var(--fowoco-spacing-16); + background: var(--fowoco-red-50); + border-radius: var(--fowoco-radius-8); +} + +.documentRequestError p { + margin: 0; + color: var(--status-critical); + font-size: 13px; +} + +.documentRequestError button { + flex-shrink: 0; + padding: 0; + background: none; + border: none; + color: var(--status-critical); + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.documentRequestCard { + margin-top: 16px; + padding: var(--fowoco-spacing-20) var(--fowoco-spacing-24); + background: var(--surface-default); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-8); +} + +.documentRequestHeader, +.documentRequestFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.documentRequestEyebrow { + margin: 0 0 4px; + color: var(--brand-primary); + font-size: 12px; + font-weight: 700; +} + +.documentRequestTitle { + margin: 0; + color: var(--text-primary); + font-size: 16px; +} + +.documentRequestVersion { + color: var(--text-secondary); + font-size: 12px; +} + +.documentRequestTypes { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; +} + +.documentRequestTypes span { + padding: 6px 10px; + background: var(--surface-subtle); + border-radius: 999px; + color: var(--text-secondary); + font-size: 12px; +} + +.documentRequestLabel { + display: block; + margin: 18px 0 8px; + color: var(--text-primary); + font-size: 13px; + font-weight: 600; +} + +.documentRequestSelect, +.documentRequestTextarea { + width: 100%; + padding: 10px 12px; + background: var(--surface-default); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-8); + color: var(--text-primary); + font: inherit; +} + +.documentRequestTextarea { + min-height: 104px; + line-height: 1.6; + resize: vertical; +} + +.documentRequestSelect:focus-visible, +.documentRequestTextarea:focus-visible { + outline: 2px solid var(--brand-primary); + outline-offset: 2px; +} + +.documentRequestFooter { + margin-top: 12px; + color: var(--text-secondary); + font-size: 12px; +} + +.documentRequestAction { + min-height: 40px; + padding: 0 16px; + background: var(--brand-primary); + border: none; + border-radius: var(--fowoco-radius-8); + color: var(--fowoco-white); + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.documentRequestAction:disabled { + cursor: wait; + opacity: 0.6; +} + .commList { display: flex; flex-direction: column; diff --git a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx index 02167db..ac68c6f 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx @@ -7,9 +7,11 @@ import type { DocumentItemResponse, DocumentPageResponse, DocumentReadinessResponse, + DocumentRequestDraftResponse, } from '../../api/documents' import type { TaskDetailResponse } from '../../api/tasks' import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport' +import { useAuthStore } from '../../store/authStore' import { useToastStore } from '../../store/toastStore' import { CaseDetailPage } from './CaseDetailPage' import { CASE_COMMUNICATION, CASE_TABS, CONTEXT_DRAWER } from './caseDetailData' @@ -124,16 +126,44 @@ function mockTaskAndActivities( activities: AuditEventResponse[] = [], readinessOverrides: Partial = {}, documents: DocumentItemResponse[] = [], + savedDocumentRequestDraft: DocumentRequestDraftResponse | null = null, ) { - vi.mocked(fetch).mockImplementation((input) => { + let currentDocumentRequestDraft = savedDocumentRequestDraft + vi.mocked(fetch).mockImplementation((input, init) => { const url = String(input) if (url.includes('/activities')) return Promise.resolve(jsonResponse(activities)) if (url.includes('/document-readiness')) return Promise.resolve(jsonResponse(readinessResponse(readinessOverrides))) if (url.includes('/document-request-draft')) { - return Promise.resolve( - jsonResponse({ draft_id: 'draft-1', version: 1, review_status: 'PENDING' }), - ) + if (init?.method !== 'PUT') { + return Promise.resolve( + currentDocumentRequestDraft + ? jsonResponse(currentDocumentRequestDraft) + : errorResponse(404, 'DOCUMENT_REQUEST_DRAFT_NOT_FOUND', '초안 없음'), + ) + } + const body = JSON.parse(String(init.body)) as { + language: string + document_types: DocumentRequestDraftResponse['document_types'] + message: string + expected_version: number + } + if ( + currentDocumentRequestDraft && + body.expected_version !== currentDocumentRequestDraft.version + ) { + return Promise.resolve(errorResponse(409, 'VERSION_CONFLICT', '초안 버전 충돌')) + } + currentDocumentRequestDraft = { + draft_id: currentDocumentRequestDraft?.draft_id ?? 'draft-1', + language: body.language, + document_types: body.document_types, + message: body.message, + version: currentDocumentRequestDraft ? currentDocumentRequestDraft.version + 1 : 0, + review_status: 'DRAFT', + updated_at: '2026-08-08T00:00:00Z', + } + return Promise.resolve(jsonResponse(currentDocumentRequestDraft)) } if (url.includes('/documents?')) return Promise.resolve(jsonResponse(documentsResponse(documents))) @@ -197,6 +227,7 @@ function mockTaskError(status: number, code: string, message: string) { } beforeEach(() => { + useAuthStore.setState({ user: null, status: 'ready' }) useToastStore.setState({ toasts: [] }) vi.stubGlobal('fetch', vi.fn()) }) @@ -338,9 +369,63 @@ describe('CaseDetailPage', () => { expect(await screen.findByText('누락 1건 · 만료 0건')).toBeInTheDocument() await user.click(screen.getByRole('tab', { name: CASE_TABS[2] })) - await user.click(await screen.findByRole('button', { name: '요청 초안 저장 →' })) + await user.click(await screen.findByRole('button', { name: '요청 초안 저장' })) expect(await screen.findByText('서류 요청 초안을 저장했습니다.')).toBeInTheDocument() + const saveCall = vi.mocked(fetch).mock.calls.find( + ([url, init]) => + String(url).includes('/document-request-draft') && init?.method === 'PUT', + ) + expect(JSON.parse(String(saveCall?.[1]?.body))).toMatchObject({ + document_types: ['ARC'], + message: '다음 서류를 제출해 주세요: 외국인등록증.', + expected_version: 0, + }) + }) + + it('restores and updates a saved document request draft', async () => { + const user = userEvent.setup() + mockTaskAndActivities( + {}, + [], + { missing: ['ARC'], completion_blocked: true }, + [], + { + draft_id: 'draft-1', + language: 'vi', + document_types: ['PASSPORT_COPY', 'CONTRACT'], + message: 'Vui lòng nộp hộ chiếu và hợp đồng lao động.', + version: 3, + review_status: 'DRAFT', + updated_at: '2026-08-08T00:00:00Z', + }, + ) + renderPage() + await screen.findByText('응웬반A 체류연장 준비') + await user.click(screen.getByRole('tab', { name: CASE_TABS[2] })) + + expect(await screen.findByDisplayValue('Vui lòng nộp hộ chiếu và hợp đồng lao động.')).toBeInTheDocument() + expect(screen.getByLabelText('안내 언어')).toHaveValue('vi') + expect(screen.getByText('저장본 v3')).toBeInTheDocument() + + const message = screen.getByLabelText('근로자 안내문') + await user.clear(message) + await user.type(message, '수정된 안내문') + await user.click(screen.getByRole('button', { name: '요청 초안 저장' })) + + const updateCall = await waitFor(() => { + const call = vi.mocked(fetch).mock.calls.find( + ([url, init]) => + String(url).includes('/document-request-draft') && init?.method === 'PUT', + ) + expect(call).toBeDefined() + return call! + }) + expect(JSON.parse(String(updateCall[1]?.body))).toMatchObject({ + message: '수정된 안내문', + expected_version: 3, + }) + expect(await screen.findByText('저장본 v4')).toBeInTheDocument() }) it('switches to the communication tab and shows message content', async () => { diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx index a469628..97fbde3 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx @@ -11,9 +11,12 @@ import { } from '../../api/approvals' import { fetchTaskActivities } from '../../api/audit' import { + fetchDocumentRequestDraft, fetchDocumentReadiness, fetchDocuments, upsertDocumentRequestDraft, + type DocumentRequestDraftResponse, + type DocumentType, } from '../../api/documents' import { ApiError, getErrorMessage } from '../../api/errors' import { cancelTask, fetchTaskById, updateChecklistItem } from '../../api/tasks' @@ -27,9 +30,11 @@ import { EmptyState } from '../../components/ui/EmptyState/EmptyState' import { StatusLabel, type StatusTone } from '../../components/ui/StatusLabel/StatusLabel' import { Tabs } from '../../components/ui/Tabs/Tabs' import { useApiQuery } from '../../hooks/useApiQuery' +import { useAuthStore } from '../../store/authStore' 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 } from '../../utils/documentLabels' import { TASK_SOURCE_LABEL, TASK_STATUS_LABEL, TASK_STATUS_TONE } from '../../utils/taskStatus' import { getDocumentViewModel } from '../../view-models/documentViewModel' import { getOperationalDateViewModel } from '../../view-models/dateViewModel' @@ -65,6 +70,53 @@ const EVIDENCE_TYPE_BY_LABEL: Record = { '화면 캡처': 'OFFICIAL_RESULT', } +interface DocumentRequestDraftForm { + taskId: string + language: string + documentTypes: DocumentType[] + message: string +} + +const GUIDANCE_LANGUAGES = [ + { value: 'ko', label: '한국어' }, + { value: 'vi', label: '베트남어' }, + { value: 'en', label: '영어' }, + { value: 'zh', label: '중국어' }, + { value: 'th', label: '태국어' }, + { value: 'id', label: '인도네시아어' }, + { value: 'km', label: '크메르어' }, + { value: 'mn', label: '몽골어' }, + { value: 'uz', label: '우즈베크어' }, + { value: 'ne', label: '네팔어' }, +] + +function uniqueDocumentTypes(types: DocumentType[]): DocumentType[] { + return [...new Set(types)] +} + +function defaultDocumentRequestMessage(documentTypes: DocumentType[]): string { + if (documentTypes.length === 0) return '' + return `다음 서류를 제출해 주세요: ${documentTypes + .map((type) => DOCUMENT_TYPE_LABEL[type]) + .join(', ')}.` +} + +async function fetchDocumentRequestDraftOrNull( + taskId: string, +): Promise { + try { + return await fetchDocumentRequestDraft(taskId) + } catch (error) { + if ( + error instanceof ApiError && + (error.status === 404 || error.code === 'DOCUMENT_REQUEST_DRAFT_NOT_FOUND') + ) { + return null + } + throw error + } +} + export function CaseDetailPage() { const { taskId } = useParams() const [activeTab, setActiveTab] = useState(CASE_TABS[0]) @@ -78,7 +130,15 @@ export function CaseDetailPage() { const [lastReissue, setLastReissue] = useState(null) const [issuedWorkerUrl, setIssuedWorkerUrl] = useState(null) const [issuedExpiresAt, setIssuedExpiresAt] = useState(null) + const [savingDocumentRequestDraft, setSavingDocumentRequestDraft] = useState(false) + const [documentRequestDraftForm, setDocumentRequestDraftForm] = + useState(null) + const [documentRequestDraftVersion, setDocumentRequestDraftVersion] = useState<{ + taskId: string + version: number + } | null>(null) const moreMenuRef = useRef(null) + const userRole = useAuthStore((state) => state.user?.role) const showToast = useToastStore((state) => state.showToast) const taskFetcher = useCallback(() => fetchTaskById(taskId ?? ''), [taskId]) @@ -96,6 +156,44 @@ export function CaseDetailPage() { const readinessFetcher = useCallback(() => fetchDocumentReadiness(taskId ?? ''), [taskId]) const { data: readiness } = useApiQuery(readinessFetcher) + const documentRequestDraftFetcher = useCallback(() => { + if (!taskId || userRole === 'VIEWER') return Promise.resolve(null) + return fetchDocumentRequestDraftOrNull(taskId) + }, [taskId, userRole]) + const { + status: documentRequestDraftStatus, + data: documentRequestDraft, + error: documentRequestDraftError, + refetch: refetchDocumentRequestDraft, + } = useApiQuery( + documentRequestDraftFetcher, + useCallback((draft: DocumentRequestDraftResponse | null) => draft === null, []), + ) + + useEffect(() => { + if (!taskId || !readiness || documentRequestDraftStatus === 'loading') return + if (documentRequestDraftStatus === 'error') return + + setDocumentRequestDraftForm((current) => { + if (current?.taskId === taskId) return current + const documentTypes = documentRequestDraft + ? documentRequestDraft.document_types + : uniqueDocumentTypes([...readiness.missing, ...readiness.expired]) + return { + taskId, + language: documentRequestDraft?.language ?? 'ko', + documentTypes, + message: + documentRequestDraft?.message ?? defaultDocumentRequestMessage(documentTypes), + } + }) + setDocumentRequestDraftVersion( + documentRequestDraft + ? { taskId, version: documentRequestDraft.version } + : null, + ) + }, [documentRequestDraft, documentRequestDraftStatus, readiness, taskId]) + const workerId = task?.worker_id const documentsFetcher = useCallback(() => { if (!workerId) return Promise.resolve({ items: [], page: 0, size: 0, total_elements: 0 }) @@ -260,16 +358,40 @@ export function CaseDetailPage() { } async function handleSaveDocumentRequestDraft() { - if (!task || !readiness) return + if (!task || !documentRequestDraftForm || savingDocumentRequestDraft) return + const message = documentRequestDraftForm.message.trim() + if (!message) { + showToast('근로자에게 표시할 안내문을 입력해 주세요.') + return + } + if (documentRequestDraftForm.documentTypes.length === 0) { + showToast('요청할 서류가 없습니다.') + return + } + setSavingDocumentRequestDraft(true) try { - await upsertDocumentRequestDraft(task.task_id, { - language: 'ko', - document_types: [...readiness.missing, ...readiness.expired], - expected_version: 0, + const savedDraft = await upsertDocumentRequestDraft(task.task_id, { + language: documentRequestDraftForm.language, + document_types: documentRequestDraftForm.documentTypes, + message, + expected_version: + documentRequestDraftVersion?.taskId === task.task_id + ? documentRequestDraftVersion.version + : 0, }) + setDocumentRequestDraftVersion({ taskId: task.task_id, version: savedDraft.version }) + setDocumentRequestDraftForm((current) => + current?.taskId === task.task_id ? { ...current, message } : current, + ) showToast('서류 요청 초안을 저장했습니다.') - } catch { - showToast('서류 요청 초안을 저장하지 못했습니다.') + } catch (error) { + showToast( + error instanceof ApiError + ? getErrorMessage(error) + : '서류 요청 초안을 저장하지 못했습니다.', + ) + } finally { + setSavingDocumentRequestDraft(false) } } @@ -692,15 +814,109 @@ export function CaseDetailPage() { })} )} - {readiness && (readiness.missing.length > 0 || readiness.expired.length > 0) && ( - + {userRole !== 'VIEWER' && documentRequestDraftStatus === 'loading' && ( +

+ 저장된 요청 초안을 확인하고 있습니다. +

)} + {userRole !== 'VIEWER' && documentRequestDraftStatus === 'error' && ( +
+

+ {documentRequestDraftError + ? getErrorMessage(documentRequestDraftError) + : '저장된 요청 초안을 불러오지 못했습니다.'} +

+ +
+ )} + {userRole !== 'VIEWER' && + documentRequestDraftStatus !== 'error' && + documentRequestDraftForm?.taskId === task.task_id && + documentRequestDraftForm.documentTypes.length > 0 && ( +
+
+
+

근로자 모바일 요청

+

+ 서류 요청 초안 +

+
+ + {documentRequestDraftVersion?.taskId === task.task_id + ? `저장본 v${documentRequestDraftVersion.version}` + : '새 초안'} + +
+ +
+ {documentRequestDraftForm.documentTypes.map((type) => ( + {DOCUMENT_TYPE_LABEL[type]} + ))} +
+ + + + + +