diff --git a/src/api/workerLinks.ts b/src/api/workerLinks.ts index b596e67..12507fa 100644 --- a/src/api/workerLinks.ts +++ b/src/api/workerLinks.ts @@ -59,11 +59,21 @@ export interface WorkerResponseSubmitResponse { export type WorkerConversationStatus = 'WAITING_WORKER' | 'NEEDS_FOLLOWUP' | 'REOPENED' +export interface WorkerResponseUploadItem { + file_id: string + file_name: string + mime_type: string + size: number + document_type: DocumentType | null + adopted: boolean +} + export interface WorkerResponseItemResponse { response_id: string response_type: WorkerResponseType message: string | null upload_ids: string[] + uploads: WorkerResponseUploadItem[] conversation_status: WorkerConversationStatus unread: boolean received_at: string @@ -77,6 +87,17 @@ export interface WorkerResponsePageResponse { total_pages: number } +export interface WorkerResponseDocumentAdoptionResponse { + response_id: string + adopted_documents: Array<{ + worker_document_id: string + file_id: string + document_type: DocumentType + }> + task_status: string + task_version: number +} + export function issueWorkerLink( taskId: string, body: WorkerLinkIssueBody, @@ -180,6 +201,20 @@ export function markTaskWorkerResponsesRead(taskId: string): Promise { }) } +export function adoptWorkerResponseDocuments( + taskId: string, + responseId: string, + expectedTaskVersion: number, +): Promise { + return apiFetch( + `/tasks/${encodeURIComponent(taskId)}/worker-responses/${encodeURIComponent(responseId)}/documents/adopt`, + { + method: 'POST', + body: JSON.stringify({ expected_task_version: expectedTaskVersion }), + }, + ) +} + export function resolveWorkerPortalUrl(workerUrlOrToken: string, origin: string): string { if (/^https?:\/\//i.test(workerUrlOrToken)) return workerUrlOrToken if (workerUrlOrToken.startsWith('/')) return new URL(workerUrlOrToken, origin).toString() diff --git a/src/pages/CaseDetailPage/CaseDetailPage.module.css b/src/pages/CaseDetailPage/CaseDetailPage.module.css index 0419b14..f025282 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.module.css +++ b/src/pages/CaseDetailPage/CaseDetailPage.module.css @@ -677,6 +677,77 @@ color: var(--text-secondary); } +.responseFiles { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 12px; +} + +.responseFileRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 12px; + background: var(--surface-subtle); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-8); +} + +.responseFileCopy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} + +.responseFileName { + overflow: hidden; + color: var(--text-primary); + font-size: 13px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.responseFileMeta { + color: var(--text-secondary); + font-size: 12px; +} + +.responseFileAction, +.adoptDocumentsAction { + flex-shrink: 0; + border-radius: var(--fowoco-radius-6); + cursor: pointer; + font-size: 12px; + font-weight: 600; +} + +.responseFileAction { + min-height: 34px; + padding: 0 12px; + background: var(--surface-default); + border: 1px solid var(--border-default); + color: var(--text-primary); +} + +.adoptDocumentsAction { + align-self: flex-end; + min-height: 38px; + padding: 0 16px; + background: var(--brand-primary); + border: 0; + color: var(--fowoco-white); +} + +.responseFileAction:disabled, +.adoptDocumentsAction:disabled { + cursor: not-allowed; + opacity: 0.5; +} + .timeline { display: flex; flex-direction: column; @@ -803,6 +874,16 @@ align-items: center; flex-direction: row; } + + .responseFileRow { + align-items: stretch; + flex-direction: column; + } + + .responseFileAction, + .adoptDocumentsAction { + align-self: stretch; + } } .contextSection + .contextSection { diff --git a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx index 1f61ba3..18b78e6 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx @@ -182,6 +182,7 @@ function mockTaskAndActivities( savedDocumentRequestDraft: DocumentRequestDraftResponse | null = null, ) { let responsesReviewed = false + let currentWorkerResponses = workerResponses let currentWorkerLinkDelivery = workerLinkDelivery let currentDocumentRequestDraft = savedDocumentRequestDraft vi.mocked(fetch).mockImplementation((input, init) => { @@ -198,8 +199,27 @@ function mockTaskAndActivities( responsesReviewed = true return Promise.resolve(new Response(null, { status: 204 })) } + if (url.includes('/worker-responses/') && url.endsWith('/documents/adopt')) { + currentWorkerResponses = currentWorkerResponses.map((response) => ({ + ...response, + uploads: response.uploads.map((upload) => ({ ...upload, adopted: true })), + })) + return Promise.resolve( + jsonResponse({ + response_id: 'response-1', + adopted_documents: + currentWorkerResponses[0]?.uploads.map((upload) => ({ + worker_document_id: `document-${upload.file_id}`, + file_id: upload.file_id, + document_type: upload.document_type, + })) ?? [], + task_status: 'APPROVED', + task_version: 2, + }), + ) + } if (url.includes('/worker-responses?')) { - const items = workerResponses.map((response) => + const items = currentWorkerResponses.map((response) => responsesReviewed ? { ...response, unread: false, conversation_status: 'REOPENED' as const } : response, @@ -605,6 +625,16 @@ describe('CaseDetailPage', () => { response_type: 'DOCUMENT_SUBMITTED', message: '여권 사본을 제출했습니다.', upload_ids: ['upload-1'], + uploads: [ + { + file_id: 'upload-1', + file_name: 'passport.pdf', + mime_type: 'application/pdf', + size: 2048, + document_type: 'PASSPORT_COPY', + adopted: false, + }, + ], conversation_status: 'NEEDS_FOLLOWUP', unread: true, received_at: '2026-07-20T01:00:00Z', @@ -618,7 +648,8 @@ describe('CaseDetailPage', () => { expect(screen.getByText('승인대기')).toBeInTheDocument() expect(screen.getByText('여권 사본을 제출했습니다.')).toBeInTheDocument() - expect(screen.getByText('제출 파일 1개')).toBeInTheDocument() + expect(screen.getByText('passport.pdf')).toBeInTheDocument() + expect(screen.getByText(/여권 사본 · 2KB · 검토 필요/)).toBeInTheDocument() expect(screen.queryByText('여권 사본 요청문 초안을 준비했습니다.')).not.toBeInTheDocument() await user.click(screen.getByRole('button', { name: '응답 확인 완료' })) @@ -689,6 +720,7 @@ describe('CaseDetailPage', () => { response_type: 'QUESTION', message: '어떤 서류가 필요한가요?', upload_ids: [], + uploads: [], conversation_status: 'NEEDS_FOLLOWUP', unread: true, received_at: '2026-07-20T01:00:00Z', @@ -704,6 +736,55 @@ describe('CaseDetailPage', () => { expect(screen.queryByRole('button', { name: '응답 확인 완료' })).not.toBeInTheDocument() }) + it('adopts a submitted file as an official worker document', async () => { + const user = userEvent.setup() + useAuthStore.setState({ + user: { name: 'hr', workplace: 'FOWOCO', role: 'HR' }, + status: 'ready', + }) + mockTaskAndActivities( + { status: 'WAITING_WORKER', version: 3 }, + [], + {}, + [], + [ + { + response_id: 'response-1', + response_type: 'DOCUMENT_SUBMITTED', + message: '여권을 제출했습니다.', + upload_ids: ['upload-1'], + uploads: [ + { + file_id: 'upload-1', + file_name: 'passport.pdf', + mime_type: 'application/pdf', + size: 2048, + document_type: 'PASSPORT_COPY', + adopted: false, + }, + ], + conversation_status: 'NEEDS_FOLLOWUP', + unread: true, + received_at: '2026-07-20T01:00:00Z', + }, + ], + ) + renderPage() + await screen.findByText('응웬반A 체류연장 준비') + + await user.click(screen.getByRole('tab', { name: CASE_TABS[3] })) + await user.click(screen.getByRole('button', { name: '확인 후 공식 서류로 등록' })) + + expect( + await screen.findByText('제출 파일을 공식 근로자 서류로 등록했습니다.'), + ).toBeInTheDocument() + await waitFor(() => expect(screen.getByText(/공식 서류 등록됨/)).toBeInTheDocument()) + const adoptionCall = vi + .mocked(fetch) + .mock.calls.find(([url]) => String(url).endsWith('/documents/adopt')) + expect(JSON.parse(String(adoptionCall?.[1]?.body))).toEqual({ expected_task_version: 3 }) + }) + it('switches to the activity tab and shows real activity content', async () => { const user = userEvent.setup() mockTaskAndActivities({}, [activity()]) diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx index 1dabca8..4d8b99a 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx @@ -24,6 +24,7 @@ import { ApiError, getErrorMessage } from '../../api/errors' import { downloadFile } from '../../api/files' import { cancelTask, fetchTaskById, updateChecklistItem } from '../../api/tasks' import { + adoptWorkerResponseDocuments, fetchTaskWorkerLinkDelivery, fetchTaskWorkerResponses, issueWorkerLink, @@ -119,6 +120,11 @@ const CASE_LIFECYCLE_LABEL: Record { @@ -209,6 +215,7 @@ export function CaseDetailPage() { const [markingLinkSent, setMarkingLinkSent] = useState(false) const [markingResponsesRead, setMarkingResponsesRead] = useState(false) const [downloadingFileId, setDownloadingFileId] = useState(null) + const [adoptingResponseId, setAdoptingResponseId] = useState(null) const [savingDocumentRequestDraft, setSavingDocumentRequestDraft] = useState(false) const [documentRequestDraftForm, setDocumentRequestDraftForm] = useState(null) @@ -611,7 +618,7 @@ export function CaseDetailPage() { expires_at: issued.expires_at, }, }) - refetchWorkerLinkDelivery() + await Promise.all([refetchTask(), refetchWorkerLinkDelivery(), refetchActivities()]) setLinkOverlay('reissued') showToast( issued.worker_url @@ -710,6 +717,31 @@ export function CaseDetailPage() { } } + async function handleAdoptResponseDocuments(responseId: string) { + if (!task || adoptingResponseId) return + setAdoptingResponseId(responseId) + try { + await adoptWorkerResponseDocuments(task.task_id, responseId, task.version) + await Promise.all([ + refetchTask(), + refetchWorkerResponses(), + refetchDocuments(), + refetchReadiness(), + refetchWorkerLinkDelivery(), + refetchActivities(), + ]) + showToast('제출 파일을 공식 근로자 서류로 등록했습니다.') + } catch (error) { + showToast( + error instanceof ApiError + ? getErrorMessage(error) + : '제출 파일을 공식 서류로 등록하지 못했습니다.', + ) + } finally { + setAdoptingResponseId(null) + } + } + if (taskStatus === 'loading') { return (
@@ -1379,10 +1411,52 @@ export function CaseDetailPage() {

{response.message?.trim() || '별도 메시지 없이 응답했습니다.'}

- {response.upload_ids.length > 0 && ( -

- 제출 파일 {response.upload_ids.length}개 -

+ {response.uploads.length > 0 && ( +
+ {response.uploads.map((upload) => ( +
+
+ + {upload.file_name} + + + {upload.document_type + ? DOCUMENT_TYPE_LABEL[upload.document_type] + : '서류 유형 미확인'} + {' · '} + {formatResponseFileSize(upload.size)} + {upload.adopted ? ' · 공식 서류 등록됨' : ' · 검토 필요'} + +
+ +
+ ))} + {response.response_type === 'DOCUMENT_SUBMITTED' && + response.uploads.some((upload) => !upload.adopted) && + userRole !== 'VIEWER' && ( + + )} +
)}
diff --git a/src/pages/LinkRequestPage/LinkRequestPage.module.css b/src/pages/LinkRequestPage/LinkRequestPage.module.css index d5f7d29..9127788 100644 --- a/src/pages/LinkRequestPage/LinkRequestPage.module.css +++ b/src/pages/LinkRequestPage/LinkRequestPage.module.css @@ -164,6 +164,60 @@ font-size: 13px; } +.questionComposer { + margin-top: 20px; + padding: 16px; + background: var(--surface-subtle); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-8); +} + +.questionLabel { + display: block; + margin-bottom: 8px; + color: var(--text-primary); + font-size: 13px; + font-weight: 700; +} + +.questionInput { + width: 100%; + min-height: 104px; + padding: 12px; + resize: vertical; + background: var(--surface-default); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-6); + color: var(--text-primary); + font: inherit; + line-height: 1.5; +} + +.questionFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 10px; + color: var(--text-secondary); + font-size: 12px; +} + +.questionSubmit { + min-height: 38px; + padding: 0 16px; + background: var(--brand-primary); + border: 0; + border-radius: var(--fowoco-radius-6); + color: var(--fowoco-white); + cursor: pointer; +} + +.questionSubmit:disabled { + cursor: not-allowed; + opacity: 0.5; +} + .footnote { margin: 12px 0 0; font-size: 12px; diff --git a/src/pages/LinkRequestPage/LinkRequestPage.test.tsx b/src/pages/LinkRequestPage/LinkRequestPage.test.tsx index bf1dab7..f6a223b 100644 --- a/src/pages/LinkRequestPage/LinkRequestPage.test.tsx +++ b/src/pages/LinkRequestPage/LinkRequestPage.test.tsx @@ -41,15 +41,20 @@ describe('LinkRequestPage', () => { }) it('shows a retryable preparing state for a link without ready draft content', async () => { - vi.mocked(fetch).mockResolvedValue(jsonResponse({ - timestamp: '2026-08-08T00:00:00Z', - status: 409, - code: 'WORKER_LINK_CONTENT_NOT_READY', - message: '근로자에게 표시할 요청 안내가 아직 준비되지 않았습니다.', - path: '/api/v1/public/worker-links/token-1', - request_id: 'request-1', - field_errors: [], - }, 409)) + vi.mocked(fetch).mockResolvedValue( + jsonResponse( + { + timestamp: '2026-08-08T00:00:00Z', + status: 409, + code: 'WORKER_LINK_CONTENT_NOT_READY', + message: '근로자에게 표시할 요청 안내가 아직 준비되지 않았습니다.', + path: '/api/v1/public/worker-links/token-1', + request_id: 'request-1', + field_errors: [], + }, + 409, + ), + ) render( @@ -85,6 +90,41 @@ describe('LinkRequestPage', () => { }) }) + it('sends the question text instead of only a question type', async () => { + const user = userEvent.setup() + vi.mocked(fetch).mockImplementation((input) => { + if (String(input).endsWith('/responses')) { + return Promise.resolve( + jsonResponse({ response_id: 'R-1', received_at: '2026-08-10T00:00:00Z' }, 201), + ) + } + return Promise.resolve(jsonResponse(VIEW)) + }) + render( + + + } /> + + , + ) + + await user.click(await screen.findByRole('button', { name: '질문이 있습니다' })) + await user.type( + screen.getByLabelText('담당자에게 물어볼 내용'), + '여권 어느 면을 찍어야 하나요?', + ) + await user.click(screen.getByRole('button', { name: '질문 보내기' })) + + expect(await screen.findByText('담당자에게 질문을 전송했습니다.')).toBeInTheDocument() + const responseCall = vi + .mocked(fetch) + .mock.calls.find(([url]) => String(url).endsWith('/responses')) + expect(JSON.parse(String(responseCall?.[1]?.body))).toMatchObject({ + response_type: 'QUESTION', + message: '여권 어느 면을 찍어야 하나요?', + }) + }) + it('shows an explicit state when opened without a token', () => { render( diff --git a/src/pages/LinkRequestPage/LinkRequestPage.tsx b/src/pages/LinkRequestPage/LinkRequestPage.tsx index 16fd7e8..d5c9261 100644 --- a/src/pages/LinkRequestPage/LinkRequestPage.tsx +++ b/src/pages/LinkRequestPage/LinkRequestPage.tsx @@ -45,6 +45,8 @@ function WorkerLinkRequest({ token }: { token: string }) { const [submittingQuestion, setSubmittingQuestion] = useState(false) const [submittingAcknowledgement, setSubmittingAcknowledgement] = useState(false) const [questionSent, setQuestionSent] = useState(false) + const [questionComposerOpen, setQuestionComposerOpen] = useState(false) + const [question, setQuestion] = useState('') const [responseError, setResponseError] = useState(null) const fetcher = useCallback(() => fetchWorkerLink(token), [token]) const { status, data, error, refetch } = useApiQuery(fetcher) @@ -57,11 +59,17 @@ function WorkerLinkRequest({ token }: { token: string }) { async function handleQuestion() { if (submittingQuestion) return + const message = question.trim() + if (!message) { + setResponseError('담당자에게 보낼 질문을 입력해 주세요.') + return + } setSubmittingQuestion(true) setResponseError(null) try { await submitWorkerResponse(token, { response_type: 'QUESTION', + message, idempotency_key: crypto.randomUUID(), }) setQuestionSent(true) @@ -118,7 +126,9 @@ function WorkerLinkRequest({ token }: { token: string }) { 보안 링크}> 선택한 파일은 이 보안 링크의 업무에만 연결됩니다.

- {questionSent &&

담당자에게 질문 의사를 전했습니다.

} + {questionComposerOpen && !questionSent && ( +
+ +