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 && ( +
+ +