diff --git a/src/api/client.test.ts b/src/api/client.test.ts index 9b7d142..040928e 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { apiFetch, getAccessToken, setAccessToken, setAuthExpiredHandler } from './client' +import { + apiFetch, + apiFetchBlob, + getAccessToken, + setAccessToken, + setAuthExpiredHandler, +} from './client' import { ApiError } from './errors' function jsonResponse(body: unknown, init: ResponseInit = {}) { @@ -52,6 +58,20 @@ describe('apiFetch', () => { expect(headers.get('Authorization')).toBe('Bearer token-abc') }) + it('returns a binary response without JSON parsing and requests any content type', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(new Blob(['file-content']), { + headers: { 'Content-Type': 'application/pdf' }, + }), + ) + + const response = await apiFetchBlob('/files/file-1/content') + + expect((await response.blob()).size).toBeGreaterThan(0) + const [, init] = vi.mocked(fetch).mock.calls[0] + expect(new Headers(init?.headers).get('Accept')).toBe('*/*') + }) + it('returns undefined for 204 No Content', async () => { vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 204 })) diff --git a/src/api/client.ts b/src/api/client.ts index 6f8ab1e..3ba4440 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -70,7 +70,7 @@ async function parseErrorBody(response: Response, path: string): Promise { const headers = new Headers(init.headers) - headers.set('Accept', 'application/json') + if (!headers.has('Accept')) headers.set('Accept', 'application/json') if (init.body && !headers.has('Content-Type') && !(init.body instanceof FormData)) { headers.set('Content-Type', 'application/json') } @@ -90,7 +90,10 @@ export interface ApiFetchOptions extends RequestInit { skipAuthRetry?: boolean } -export async function apiFetch(path: string, options: ApiFetchOptions = {}): Promise { +async function requestWithAuth( + path: string, + options: ApiFetchOptions = {}, +): Promise { const { skipAuthRetry, ...init } = options let response: Response @@ -119,6 +122,12 @@ export async function apiFetch(path: string, options: ApiFetchOptions = {}): throw await parseErrorBody(response, path) } + return response +} + +export async function apiFetch(path: string, options: ApiFetchOptions = {}): Promise { + const response = await requestWithAuth(path, options) + // 204는 물론이고, password-reset-requests처럼 body 없이 202만 내려주는 응답도 있어 // status 코드로만 분기하지 않고 실제 body가 비어있는지로 판단한다. const text = await response.text() @@ -128,3 +137,12 @@ export async function apiFetch(path: string, options: ApiFetchOptions = {}): return JSON.parse(text) as T } + +export function apiFetchBlob(path: string, options: ApiFetchOptions = {}): Promise { + const headers = new Headers(options.headers) + headers.set('Accept', '*/*') + return requestWithAuth(path, { + ...options, + headers, + }) +} 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/files.test.ts b/src/api/files.test.ts index b62222a..0182d7b 100644 --- a/src/api/files.test.ts +++ b/src/api/files.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { uploadFile } from './files' +import { downloadFile, uploadFile } from './files' function jsonResponse(body: unknown) { return new Response(JSON.stringify(body), { status: 201, headers: { 'Content-Type': 'application/json' } }) @@ -30,3 +30,22 @@ describe('uploadFile', () => { expect(headers.has('Content-Type')).toBe(false) }) }) + +describe('downloadFile', () => { + it('downloads encoded file content and reads the server filename', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(new Blob(['pdf']), { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': "attachment; filename*=UTF-8''passport%20copy.pdf", + }, + }), + ) + + const result = await downloadFile('file/1') + + expect(result.file_name).toBe('passport copy.pdf') + expect(result.blob.size).toBeGreaterThan(0) + expect(String(vi.mocked(fetch).mock.calls[0][0])).toContain('/files/file%2F1/content') + }) +}) diff --git a/src/api/files.ts b/src/api/files.ts index 229da38..0de8c3d 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -1,7 +1,7 @@ -import { apiFetch } from './client' +import { apiFetch, apiFetchBlob } from './client' -// fowoco/server FileController 기준 — 분석·증빙·근로자 제출용 공통 파일 업로드. -// 허용 형식은 image/jpeg·png·webp, application/pdf, 최대 20MB로 서버에 고정돼 있다. +// fowoco/server FileController/FileService 기준 — 분석·증빙·근로자 제출용 공통 파일 업로드. +// JPEG·PNG·WEBP·PDF와 유효한 HWP/HWPX 시그니처를 허용하며 최대 크기는 20MB다. export type ScanStatus = 'NOT_SCANNED' | 'CLEAN' | 'INFECTED' export interface FileUploadResponse { @@ -27,3 +27,31 @@ export function uploadFile(params: UploadFileParams): Promise('/files', { method: 'POST', body: formData }) } + +export interface FileDownloadResponse { + blob: Blob + file_name: string | null +} + +function getDownloadFileName(contentDisposition: string | null): string | null { + if (!contentDisposition) return null + + const encoded = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1] + if (encoded) { + try { + return decodeURIComponent(encoded) + } catch { + return encoded + } + } + + return contentDisposition.match(/filename="([^"]+)"/i)?.[1] ?? null +} + +export async function downloadFile(fileId: string): Promise { + const response = await apiFetchBlob(`/files/${encodeURIComponent(fileId)}/content`) + return { + blob: await response.blob(), + file_name: getDownloadFileName(response.headers.get('Content-Disposition')), + } +} diff --git a/src/api/workerLinks.ts b/src/api/workerLinks.ts index e041452..298da48 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' @@ -34,7 +35,9 @@ export interface WorkerLinkDeliveryResponse { 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 81e9348..0419b14 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.module.css +++ b/src/pages/CaseDetailPage/CaseDetailPage.module.css @@ -404,6 +404,7 @@ .documentRow { display: flex; align-items: center; + flex-wrap: wrap; gap: 16px; padding: var(--fowoco-spacing-16) var(--fowoco-spacing-24); background: var(--surface-default); @@ -469,6 +470,143 @@ line-height: 20px; } +.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 abc7ea1..67e9a7b 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx @@ -8,12 +8,10 @@ import type { DocumentItemResponse, DocumentPageResponse, DocumentReadinessResponse, + DocumentRequestDraftResponse, } from '../../api/documents' import type { TaskDetailResponse } from '../../api/tasks' -import type { - WorkerLinkDeliveryResponse, - WorkerResponseItemResponse, -} from '../../api/workerLinks' +import type { WorkerLinkDeliveryResponse, WorkerResponseItemResponse } from '../../api/workerLinks' import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport' import { useAuthStore } from '../../store/authStore' import { useToastStore } from '../../store/toastStore' @@ -181,9 +179,11 @@ function mockTaskAndActivities( workerResponses: WorkerResponseItemResponse[] = [], workerLinkDelivery: WorkerLinkDeliveryResponse | null = null, caseProjection: CaseProjectionResponse | null = null, + savedDocumentRequestDraft: DocumentRequestDraftResponse | null = null, ) { let responsesReviewed = false let currentWorkerLinkDelivery = workerLinkDelivery + let currentDocumentRequestDraft = savedDocumentRequestDraft vi.mocked(fetch).mockImplementation((input, init) => { const url = String(input) if (url.includes('/activities')) return Promise.resolve(jsonResponse(activities)) @@ -222,9 +222,35 @@ function mockTaskAndActivities( ) } 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))) @@ -338,7 +364,9 @@ function mockTaskError(status: number, code: string, message: string) { ) } if (url.endsWith('/worker-link')) { - return Promise.resolve(errorResponse(404, 'WORKER_LINK_RESOURCE_NOT_FOUND', '근로자 링크 없음')) + return Promise.resolve( + errorResponse(404, 'WORKER_LINK_RESOURCE_NOT_FOUND', '근로자 링크 없음'), + ) } return Promise.resolve(errorResponse(status, code, message)) }) @@ -351,6 +379,7 @@ beforeEach(() => { }) afterEach(() => { + vi.restoreAllMocks() vi.unstubAllGlobals() }) @@ -458,6 +487,9 @@ describe('CaseDetailPage', () => { it('switches to the document tab and shows real document content', async () => { const user = userEvent.setup() + const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:file-1') + const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + const clickAnchor = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) mockTaskAndActivities({}, [], {}, [ { worker_document_id: 'doc-1', @@ -476,6 +508,12 @@ describe('CaseDetailPage', () => { expect(await screen.findByText('여권 사본')).toBeInTheDocument() expect(screen.getByText('완료')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '다운로드' })) + + expect(createObjectUrl).toHaveBeenCalledTimes(1) + expect(clickAnchor).toHaveBeenCalledTimes(1) + expect(revokeObjectUrl).toHaveBeenCalledWith('blob:file-1') }) it('shows the document-readiness gate and saves a document request draft when documents are missing', async () => { @@ -487,24 +525,92 @@ 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('shows real unread worker responses and marks them as reviewed', async () => { + it('restores and updates a saved document request draft', async () => { const user = userEvent.setup() - mockTaskAndActivities({}, [], {}, [], [ + mockTaskAndActivities( + {}, + [], + { missing: ['ARC'], completion_blocked: true }, + [], + [], + null, + null, { - response_id: 'response-1', - response_type: 'DOCUMENT_SUBMITTED', - message: '여권 사본을 제출했습니다.', - upload_ids: ['upload-1'], - conversation_status: 'NEEDS_FOLLOWUP', - unread: true, - received_at: '2026-07-20T01:00:00Z', + 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('shows real unread worker responses and marks them as reviewed', async () => { + const user = userEvent.setup() + mockTaskAndActivities( + {}, + [], + {}, + [], + [ + { + response_id: 'response-1', + response_type: 'DOCUMENT_SUBMITTED', + message: '여권 사본을 제출했습니다.', + upload_ids: ['upload-1'], + conversation_status: 'NEEDS_FOLLOWUP', + unread: true, + received_at: '2026-07-20T01:00:00Z', + }, + ], + ) renderPage() await screen.findByText('응웬반A 체류연장 준비') @@ -572,17 +678,23 @@ describe('CaseDetailPage', () => { user: { name: 'viewer', workplace: 'FOWOCO', role: 'VIEWER' }, status: 'ready', }) - mockTaskAndActivities({}, [], {}, [], [ - { - response_id: 'response-1', - response_type: 'QUESTION', - message: '어떤 서류가 필요한가요?', - upload_ids: [], - conversation_status: 'NEEDS_FOLLOWUP', - unread: true, - received_at: '2026-07-20T01:00:00Z', - }, - ]) + mockTaskAndActivities( + {}, + [], + {}, + [], + [ + { + response_id: 'response-1', + response_type: 'QUESTION', + message: '어떤 서류가 필요한가요?', + upload_ids: [], + conversation_status: 'NEEDS_FOLLOWUP', + unread: true, + received_at: '2026-07-20T01:00:00Z', + }, + ], + ) renderPage() await screen.findByText('응웬반A 체류연장 준비') @@ -613,11 +725,7 @@ describe('CaseDetailPage', () => { it('blocks completion and offers retry when document readiness cannot be verified', async () => { const user = userEvent.setup() - mockTaskAndActivities( - {}, - [], - errorResponse(503, 'SERVICE_UNAVAILABLE', '서류 상태 확인 지연'), - ) + mockTaskAndActivities({}, [], errorResponse(503, 'SERVICE_UNAVAILABLE', '서류 상태 확인 지연')) renderPage() expect(await screen.findByText('조회 실패')).toBeInTheDocument() @@ -627,9 +735,7 @@ describe('CaseDetailPage', () => { await user.click(screen.getByRole('button', { name: '다시 조회 →' })) expect( - vi.mocked(fetch).mock.calls.filter(([url]) => - String(url).includes('/document-readiness'), - ), + vi.mocked(fetch).mock.calls.filter(([url]) => String(url).includes('/document-readiness')), ).toHaveLength(2) }) @@ -818,9 +924,7 @@ describe('CaseDetailPage', () => { expect(await within(drawer).findByText('Case 현황')).toBeInTheDocument() expect(within(drawer).getByText('응웬반A')).toBeInTheDocument() expect( - vi.mocked(fetch).mock.calls.some(([url]) => - String(url).endsWith('/cases/C-1/projection'), - ), + vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/cases/C-1/projection')), ).toBe(true) }) diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx index 27019df..476f9d2 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx @@ -13,11 +13,15 @@ import { import { fetchTaskActivities } from '../../api/audit' import { fetchCaseProjection, type CaseProjectionResponse } from '../../api/cases' import { + fetchDocumentRequestDraft, fetchDocumentReadiness, fetchDocuments, upsertDocumentRequestDraft, + type DocumentRequestDraftResponse, + type DocumentType, } from '../../api/documents' import { ApiError, getErrorMessage } from '../../api/errors' +import { downloadFile } from '../../api/files' import { cancelTask, fetchTaskById, updateChecklistItem } from '../../api/tasks' import { fetchTaskWorkerLinkDelivery, @@ -42,6 +46,8 @@ import { useAuthStore } from '../../store/authStore' import { useToastStore } from '../../store/toastStore' import { ACTOR_TYPE_TO_AGENT_SOURCE, getAuditActionLabel } from '../../utils/auditLabels' import { formatEventTime } from '../../utils/datetime' +import { saveBlobAsFile } from '../../utils/fileDownload' +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' @@ -128,6 +134,53 @@ async function fetchTaskWorkerLinkDeliveryOrNull( } } +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 [searchParams, setSearchParams] = useSearchParams() @@ -140,8 +193,7 @@ export function CaseDetailPage() { const [actionPending, setActionPending] = useState(false) const [togglingItemId, setTogglingItemId] = useState(null) const [linkOverlay, setLinkOverlay] = useState('none') - const [deliveryConfirmReturn, setDeliveryConfirmReturn] = - useState('none') + const [deliveryConfirmReturn, setDeliveryConfirmReturn] = useState('none') const [lastReissue, setLastReissue] = useState(null) const [issuedWorkerUrl, setIssuedWorkerUrl] = useState(null) const [issuedExpiresAt, setIssuedExpiresAt] = useState(null) @@ -151,6 +203,14 @@ export function CaseDetailPage() { } | null>(null) const [markingLinkSent, setMarkingLinkSent] = useState(false) const [markingResponsesRead, setMarkingResponsesRead] = useState(false) + const [downloadingFileId, setDownloadingFileId] = 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) @@ -179,6 +239,41 @@ export function CaseDetailPage() { refetch: refetchReadiness, } = 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 }) @@ -366,11 +461,7 @@ export function CaseDetailPage() { refetchActivities() const detail = error instanceof ApiError ? getErrorMessage(error) : '업무를 완료하지 못했습니다.' - showToast( - externalSubmissionRecorded - ? `외부 제출 기록은 저장됐습니다. ${detail}` - : detail, - ) + showToast(externalSubmissionRecorded ? `외부 제출 기록은 저장됐습니다. ${detail}` : detail) } finally { setActionPending(false) } @@ -427,16 +518,55 @@ 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) + } + } + + async function handleDownloadDocument(fileId: string, fallbackName: string) { + if (downloadingFileId) return + setDownloadingFileId(fileId) + try { + const downloaded = await downloadFile(fileId) + saveBlobAsFile(downloaded.blob, downloaded.file_name ?? fallbackName) + } catch (error) { + showToast( + error instanceof ApiError ? getErrorMessage(error) : '첨부 파일을 내려받지 못했습니다.', + ) + } finally { + setDownloadingFileId(null) } } @@ -831,9 +961,9 @@ export function CaseDetailPage() { ? '조회 실패' : !readiness ? '확인할 정보 없음' - : readiness.completion_blocked - ? `누락 ${readiness.missing.length}건 · 만료 ${readiness.expired.length}건` - : '모두 확인됨' + : readiness.completion_blocked + ? `누락 ${readiness.missing.length}건 · 만료 ${readiness.expired.length}건` + : '모두 확인됨' } tone={ readinessStatus === 'error' @@ -974,25 +1104,133 @@ export function CaseDetailPage() {
{documents.map((document) => { const view = getDocumentViewModel(document) + const fileId = document.file_id return (
{view.typeLabel} {view.statusLabel} {view.expiry.display} + {fileId && ( + + )}
) })}
)} - {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]} + ))} +
+ + + + + +