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 && (
+
+ )}
+ {questionSent && 담당자에게 질문을 전송했습니다.
}
{responseError && (
{responseError}
@@ -190,9 +227,12 @@ function WorkerLinkRequest({ token }: { token: string }) {
disabled={
!canAskQuestion || submittingQuestion || submittingAcknowledgement || questionSent
}
- onClick={handleQuestion}
+ onClick={() => {
+ setQuestionComposerOpen(true)
+ setResponseError(null)
+ }}
>
- {questionSent ? '질문 의사 전송됨' : '질문이 있습니다'}
+ {questionSent ? '질문 전송됨' : questionComposerOpen ? '질문 작성 중' : '질문이 있습니다'}