From 27a02db0c32f4dae92742bf616eeb4f8c8bcbf83 Mon Sep 17 00:00:00 2001
From: hywznn
Date: Wed, 12 Aug 2026 01:31:01 +0900
Subject: [PATCH 1/2] =?UTF-8?q?feat(workerlink):=20=EB=AA=A8=EB=B0=94?=
=?UTF-8?q?=EC=9D=BC=20=EC=A7=88=EB=AC=B8=EA=B3=BC=20=EC=A0=9C=EC=B6=9C=20?=
=?UTF-8?q?=EC=84=9C=EB=A5=98=20=EA=B2=80=ED=86=A0=20=ED=9D=90=EB=A6=84=20?=
=?UTF-8?q?=EC=97=B0=EA=B2=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
근로자가 실제 질문 내용을 작성해 전송하고, HR이 Case 소통 화면에서 제출 파일을 확인·다운로드한 뒤 공식 서류로 채택할 수 있도록 연결했습니다.
---
src/api/workerLinks.ts | 35 ++++++++
.../CaseDetailPage/CaseDetailPage.module.css | 81 ++++++++++++++++++
src/pages/CaseDetailPage/CaseDetailPage.tsx | 84 +++++++++++++++++--
.../LinkRequestPage.module.css | 54 ++++++++++++
src/pages/LinkRequestPage/LinkRequestPage.tsx | 48 ++++++++++-
.../LinkUploadPage/LinkUploadPage.module.css | 54 ++++++++++++
src/pages/LinkUploadPage/LinkUploadPage.tsx | 77 +++++++++++++----
7 files changed, 410 insertions(+), 23 deletions(-)
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.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.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 ? '질문 작성 중' : '질문이 있습니다'}