From c91f0e2c41215360e96a7f883df9008aabc91fb0 Mon Sep 17 00:00:00 2001 From: hywznn Date: Sat, 8 Aug 2026 22:58:33 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EA=B7=BC=EB=A1=9C=EC=9E=90=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EB=82=B4=EC=9A=A9=EA=B3=BC=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=EC=A0=9C=EC=B6=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/workerLinks.ts | 3 + .../LinkRequestPage.module.css | 39 ++++ .../LinkRequestPage/LinkRequestPage.test.tsx | 27 +++ src/pages/LinkRequestPage/LinkRequestPage.tsx | 37 ++- .../LinkUploadPage/LinkUploadPage.module.css | 43 +++- .../LinkUploadPage/LinkUploadPage.test.tsx | 121 +++++++++- src/pages/LinkUploadPage/LinkUploadPage.tsx | 214 ++++++++++++------ 7 files changed, 408 insertions(+), 76 deletions(-) diff --git a/src/api/workerLinks.ts b/src/api/workerLinks.ts index b4e5fde..0f5802f 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' @@ -19,7 +20,9 @@ export interface WorkerLinkIssueResponse { export interface WorkerLinkViewResponse { guidance: string + language: string due_date: string | null + requested_document_types: DocumentType[] allowed_responses: WorkerResponseType[] } diff --git a/src/pages/LinkRequestPage/LinkRequestPage.module.css b/src/pages/LinkRequestPage/LinkRequestPage.module.css index b30398b..d5f7d29 100644 --- a/src/pages/LinkRequestPage/LinkRequestPage.module.css +++ b/src/pages/LinkRequestPage/LinkRequestPage.module.css @@ -40,6 +40,12 @@ color: var(--status-warning); } +.language { + margin: 6px 0 0; + color: var(--text-secondary); + font-size: 12px; +} + .divider { margin: 20px 0; border: none; @@ -53,6 +59,39 @@ color: var(--text-primary); } +.requestedDocuments { + margin-top: 24px; + padding: 16px 20px; + background: var(--surface-subtle); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-8); +} + +.requestedDocumentsTitle { + margin: 0; + color: var(--text-primary); + font-size: 13px; + font-weight: 700; +} + +.requestedDocumentsList { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 12px 0 0; + padding: 0; + list-style: none; +} + +.requestedDocumentsList li { + padding: 6px 10px; + background: var(--surface-default); + border: 1px solid var(--border-default); + border-radius: 999px; + color: var(--text-primary); + font-size: 12px; +} + .privacy { min-height: 96px; margin: 30px 0 0; diff --git a/src/pages/LinkRequestPage/LinkRequestPage.test.tsx b/src/pages/LinkRequestPage/LinkRequestPage.test.tsx index f66a7f8..bf1dab7 100644 --- a/src/pages/LinkRequestPage/LinkRequestPage.test.tsx +++ b/src/pages/LinkRequestPage/LinkRequestPage.test.tsx @@ -13,7 +13,9 @@ function jsonResponse(body: unknown, status = 200) { const VIEW = { guidance: '여권 사진면 사본을 제출해 주세요.', + language: 'vi', due_date: '2026-08-10', + requested_document_types: ['PASSPORT_COPY', 'CONTRACT'], allowed_responses: ['ACKNOWLEDGED', 'QUESTION', 'DOCUMENT_SUBMITTED'], } @@ -33,6 +35,31 @@ describe('LinkRequestPage', () => { expect(await screen.findByText('여권 사진면 사본을 제출해 주세요.')).toBeInTheDocument() expect(screen.getByText(/2026.08.10/)).toBeInTheDocument() + expect(screen.getByText('베트남어 안내')).toBeInTheDocument() + expect(screen.getByText('제출할 서류 2개')).toBeInTheDocument() + expect(screen.getByText('근로계약서')).toBeInTheDocument() + }) + + 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)) + render( + + + } /> + + , + ) + + expect(await screen.findByText('요청 내용을 준비하고 있습니다')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '다시 시도' })).toBeInTheDocument() }) it('keeps the token when navigating to the upload page', async () => { diff --git a/src/pages/LinkRequestPage/LinkRequestPage.tsx b/src/pages/LinkRequestPage/LinkRequestPage.tsx index 920ab41..16fd7e8 100644 --- a/src/pages/LinkRequestPage/LinkRequestPage.tsx +++ b/src/pages/LinkRequestPage/LinkRequestPage.tsx @@ -5,9 +5,23 @@ import { fetchWorkerLink, submitWorkerResponse } from '../../api/workerLinks' import { MobileShell } from '../../components/mobile/MobileShell' import { EmptyState } from '../../components/ui/EmptyState/EmptyState' import { useApiQuery } from '../../hooks/useApiQuery' +import { DOCUMENT_TYPE_LABEL } from '../../utils/documentLabels' import { getOperationalDateViewModel } from '../../view-models/dateViewModel' import styles from './LinkRequestPage.module.css' +const LANGUAGE_LABEL: Record = { + ko: '한국어', + vi: '베트남어', + en: '영어', + zh: '중국어', + th: '태국어', + id: '인도네시아어', + km: '크메르어', + mn: '몽골어', + uz: '우즈베크어', + ne: '네팔어', +} + export function LinkRequestPage() { const { token } = useParams() @@ -98,13 +112,19 @@ function WorkerLinkRequest({ token }: { token: string }) { } if (status === 'error' || !data) { + const contentNotReady = + error instanceof ApiError && error.code === 'WORKER_LINK_CONTENT_NOT_READY' return ( 보안 링크}>

{due.display}

+

{LANGUAGE_LABEL[data.language] ?? data.language} 안내


{data.guidance}

+
+

+ 제출할 서류 {requestedDocumentTypes.length}개 +

+
    + {requestedDocumentTypes.map((type) => ( +
  • {DOCUMENT_TYPE_LABEL[type]}
  • + ))} +
+
+

이 업무에 필요한 정보만 표시됩니다.

선택한 파일은 이 보안 링크의 업무에만 연결됩니다.

diff --git a/src/pages/LinkUploadPage/LinkUploadPage.module.css b/src/pages/LinkUploadPage/LinkUploadPage.module.css index 9c40bc9..91859e5 100644 --- a/src/pages/LinkUploadPage/LinkUploadPage.module.css +++ b/src/pages/LinkUploadPage/LinkUploadPage.module.css @@ -13,11 +13,43 @@ color: var(--text-secondary); } +.requestList { + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 24px; +} + +.requestCard { + padding: 16px; + background: var(--surface-subtle); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-10); +} + +.requestCardHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.requestCardHeader span { + color: var(--text-secondary); + font-size: 12px; +} + +.requestTitle { + margin: 0; + color: var(--text-primary); + font-size: 15px; +} + .dropzone { width: 100%; - min-height: 148px; - margin-top: 20px; - padding: 20px; + min-height: 124px; + margin-top: 12px; + padding: 16px; background: var(--surface-default); border: 2px solid var(--brand-primary); border-radius: var(--fowoco-radius-8); @@ -138,6 +170,11 @@ cursor: pointer; } +.removeFile:disabled { + cursor: wait; + opacity: 0.5; +} + .helpLabel { margin: 24px 0 0; font-size: 13px; diff --git a/src/pages/LinkUploadPage/LinkUploadPage.test.tsx b/src/pages/LinkUploadPage/LinkUploadPage.test.tsx index c4c160c..140d7a4 100644 --- a/src/pages/LinkUploadPage/LinkUploadPage.test.tsx +++ b/src/pages/LinkUploadPage/LinkUploadPage.test.tsx @@ -28,7 +28,8 @@ beforeEach(() => { return Promise.resolve(jsonResponse({ response_id: 'R-1', received_at: '2026-08-04T00:00:00Z' }, 201)) } return Promise.resolve(jsonResponse({ - guidance: '여권 사본을 제출해 주세요.', due_date: '2026-08-10', + guidance: '여권 사본을 제출해 주세요.', language: 'ko', due_date: '2026-08-10', + requested_document_types: ['PASSPORT_COPY'], allowed_responses: ['QUESTION', 'NOT_UNDERSTOOD', 'DIFFICULT', 'DOCUMENT_SUBMITTED'], })) })) @@ -42,7 +43,7 @@ describe('LinkUploadPage', () => { renderPage() const file = new File(['passport'], 'passport_photo.jpg', { type: 'image/jpeg' }) - await user.upload(await screen.findByLabelText('제출할 파일 선택'), file) + await user.upload(await screen.findByLabelText('여권 사본 제출할 파일 선택'), file) expect(screen.getByText('passport_photo.jpg')).toBeInTheDocument() expect(screen.getByText(/제출 전/)).toBeInTheDocument() @@ -54,8 +55,8 @@ describe('LinkUploadPage', () => { renderPage() const file = new File(['passport'], 'passport_photo.jpg', { type: 'image/jpeg' }) - await user.upload(await screen.findByLabelText('제출할 파일 선택'), file) - await user.click(screen.getByRole('button', { name: '삭제' })) + await user.upload(await screen.findByLabelText('여권 사본 제출할 파일 선택'), file) + await user.click(screen.getByRole('button', { name: '제거' })) expect(screen.queryByText('passport_photo.jpg')).not.toBeInTheDocument() }) @@ -65,27 +66,131 @@ describe('LinkUploadPage', () => { renderPage() const file = new File(['script'], 'worker.exe', { type: 'application/octet-stream' }) - await user.upload(await screen.findByLabelText('제출할 파일 선택'), file) + await user.upload(await screen.findByLabelText('여권 사본 제출할 파일 선택'), file) - expect(screen.getByRole('alert')).toHaveTextContent('JPG, PNG, PDF 파일만 선택할 수 있습니다.') + expect(screen.getByRole('alert')).toHaveTextContent('JPG, PNG, WEBP, PDF 파일만 선택할 수 있습니다.') expect(screen.queryByText('worker.exe')).not.toBeInTheDocument() }) it('uploads the selected file and submits the upload id as a worker response', async () => { const user = userEvent.setup() renderPage() - await screen.findByRole('heading', { name: '사진 또는 파일을 추가해 주세요' }) + await screen.findByRole('heading', { name: '요청받은 서류를 추가해 주세요' }) const file = new File(['passport'], 'passport_photo.jpg', { type: 'image/jpeg' }) - await user.upload(await screen.findByLabelText('제출할 파일 선택'), file) + await user.upload(await screen.findByLabelText('여권 사본 제출할 파일 선택'), file) await user.click(screen.getByRole('button', { name: '서류 제출' })) expect(await screen.findByText('서류를 제출했습니다')).toBeInTheDocument() const calls = vi.mocked(fetch).mock.calls expect(calls.some(([url]) => String(url).endsWith('/documents'))).toBe(true) + const uploadCall = calls.find(([url]) => String(url).endsWith('/documents')) + expect((uploadCall?.[1]?.body as FormData).get('documentType')).toBe('PASSPORT_COPY') const responseCall = calls.find(([url]) => String(url).endsWith('/responses')) expect(JSON.parse(responseCall?.[1]?.body as string)).toMatchObject({ response_type: 'DOCUMENT_SUBMITTED', upload_ids: ['U-1'], }) }) + + it('uploads every requested document with its type before submitting one response', async () => { + const user = userEvent.setup() + let uploadCount = 0 + vi.mocked(fetch).mockImplementation((input) => { + const url = String(input) + if (url.endsWith('/documents')) { + uploadCount += 1 + return Promise.resolve(jsonResponse({ + upload_id: `U-${uploadCount}`, + file_name: `document-${uploadCount}.jpg`, + size: 8, + expires_at: '2026-08-07T00:00:00Z', + }, 201)) + } + if (url.endsWith('/responses')) { + return Promise.resolve(jsonResponse({ + response_id: 'R-1', + received_at: '2026-08-04T00:00:00Z', + }, 201)) + } + return Promise.resolve(jsonResponse({ + guidance: '여권과 계약서를 제출해 주세요.', + language: 'ko', + due_date: '2026-08-10', + requested_document_types: ['PASSPORT_COPY', 'CONTRACT'], + allowed_responses: ['DOCUMENT_SUBMITTED'], + })) + }) + renderPage() + + await user.upload( + await screen.findByLabelText('여권 사본 제출할 파일 선택'), + new File(['passport'], 'passport.jpg', { type: 'image/jpeg' }), + ) + await user.upload( + screen.getByLabelText('근로계약서 제출할 파일 선택'), + new File(['contract'], 'contract.pdf', { type: 'application/pdf' }), + ) + expect(screen.getByText('2 / 2')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: '서류 제출' })) + + expect(await screen.findByText('서류를 제출했습니다')).toBeInTheDocument() + const calls = vi.mocked(fetch).mock.calls + const uploadTypes = calls + .filter(([url]) => String(url).endsWith('/documents')) + .map(([, init]) => (init?.body as FormData).get('documentType')) + expect(uploadTypes).toEqual(['PASSPORT_COPY', 'CONTRACT']) + const responseCall = calls.find(([url]) => String(url).endsWith('/responses')) + expect(JSON.parse(String(responseCall?.[1]?.body))).toMatchObject({ + response_type: 'DOCUMENT_SUBMITTED', + upload_ids: ['U-1', 'U-2'], + }) + }) + + it('reuses uploaded files and the response idempotency key after a network retry', async () => { + const user = userEvent.setup() + let responseAttempts = 0 + vi.mocked(fetch).mockImplementation((input) => { + const url = String(input) + if (url.endsWith('/documents')) { + return Promise.resolve(jsonResponse({ + upload_id: 'U-1', + file_name: 'passport.jpg', + size: 8, + expires_at: '2026-08-07T00:00:00Z', + }, 201)) + } + if (url.endsWith('/responses')) { + responseAttempts += 1 + if (responseAttempts === 1) return Promise.reject(new TypeError('network failed')) + return Promise.resolve(jsonResponse({ + response_id: 'R-1', + received_at: '2026-08-04T00:00:00Z', + }, 201)) + } + return Promise.resolve(jsonResponse({ + guidance: '여권을 제출해 주세요.', + language: 'ko', + due_date: '2026-08-10', + requested_document_types: ['PASSPORT_COPY'], + allowed_responses: ['DOCUMENT_SUBMITTED'], + })) + }) + renderPage() + await user.upload( + await screen.findByLabelText('여권 사본 제출할 파일 선택'), + new File(['passport'], 'passport.jpg', { type: 'image/jpeg' }), + ) + + await user.click(screen.getByRole('button', { name: '서류 제출' })) + expect(await screen.findByRole('alert')).toHaveTextContent('네트워크 상태를 확인') + await user.click(screen.getByRole('button', { name: '서류 제출' })) + + expect(await screen.findByText('서류를 제출했습니다')).toBeInTheDocument() + const calls = vi.mocked(fetch).mock.calls + expect(calls.filter(([url]) => String(url).endsWith('/documents'))).toHaveLength(1) + const responseCalls = calls.filter(([url]) => String(url).endsWith('/responses')) + expect(responseCalls).toHaveLength(2) + const responseBodies = responseCalls.map(([, init]) => JSON.parse(String(init?.body))) + expect(responseBodies[0].idempotency_key).toBe(responseBodies[1].idempotency_key) + }) }) diff --git a/src/pages/LinkUploadPage/LinkUploadPage.tsx b/src/pages/LinkUploadPage/LinkUploadPage.tsx index db17880..bf0554f 100644 --- a/src/pages/LinkUploadPage/LinkUploadPage.tsx +++ b/src/pages/LinkUploadPage/LinkUploadPage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react' import { useNavigate, useParams } from 'react-router-dom' +import type { DocumentType } from '../../api/documents' import { ApiError, getErrorMessage } from '../../api/errors' import { fetchWorkerLink, @@ -11,11 +12,12 @@ import { import { MobileShell } from '../../components/mobile/MobileShell' import { EmptyState } from '../../components/ui/EmptyState/EmptyState' import { useApiQuery } from '../../hooks/useApiQuery' +import { DOCUMENT_TYPE_LABEL } from '../../utils/documentLabels' import styles from './LinkUploadPage.module.css' import { HELP_LINKS } from './linkUploadData' -const MAX_FILE_SIZE = 10 * 1024 * 1024 -const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'application/pdf'] +const MAX_FILE_SIZE = 20 * 1024 * 1024 +const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'] function formatFileSize(bytes: number) { if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))}KB` @@ -25,12 +27,16 @@ function formatFileSize(bytes: number) { export function LinkUploadPage() { const { token } = useParams() const navigate = useNavigate() - const fileInputRef = useRef(null) - const [file, setFile] = useState(null) - const [fileError, setFileError] = useState(null) + const fileInputRefs = useRef>>({}) + const uploadRequestIds = useRef>>({}) + const responseRequestId = useRef(null) + const [files, setFiles] = useState>>({}) + const [fileErrors, setFileErrors] = useState>>({}) + const [uploadedIds, setUploadedIds] = useState>>({}) const [submitting, setSubmitting] = useState(false) const [submission, setSubmission] = useState(null) const [responseMessage, setResponseMessage] = useState(null) + const [submissionError, setSubmissionError] = useState(null) const fetcher = useCallback( () => (token ? fetchWorkerLink(token) : Promise.reject(new Error('missing token'))), [token], @@ -43,42 +49,77 @@ export function LinkUploadPage() { } }, [error, navigate, status, token]) - function handleFileChange(event: ChangeEvent) { + function handleFileChange(documentType: DocumentType, event: ChangeEvent) { const selected = event.target.files?.[0] ?? null - setFileError(null) + setFileErrors((current) => ({ ...current, [documentType]: undefined })) + setSubmissionError(null) if (!selected) return if (!ACCEPTED_FILE_TYPES.includes(selected.type)) { - setFile(null) - setFileError('JPG, PNG, PDF 파일만 선택할 수 있습니다.') + setFiles((current) => ({ ...current, [documentType]: undefined })) + setFileErrors((current) => ({ + ...current, + [documentType]: 'JPG, PNG, WEBP, PDF 파일만 선택할 수 있습니다.', + })) event.target.value = '' return } if (selected.size > MAX_FILE_SIZE) { - setFile(null) - setFileError('파일 크기는 10MB 이하여야 합니다.') + setFiles((current) => ({ ...current, [documentType]: undefined })) + setFileErrors((current) => ({ + ...current, + [documentType]: '파일 크기는 20MB 이하여야 합니다.', + })) event.target.value = '' return } - setFile(selected) + setFiles((current) => ({ ...current, [documentType]: selected })) + setUploadedIds((current) => ({ ...current, [documentType]: undefined })) + delete uploadRequestIds.current[documentType] + responseRequestId.current = null } - function handleRemoveFile() { - setFile(null) - setFileError(null) - if (fileInputRef.current) fileInputRef.current.value = '' + function handleRemoveFile(documentType: DocumentType) { + setFiles((current) => ({ ...current, [documentType]: undefined })) + setFileErrors((current) => ({ ...current, [documentType]: undefined })) + setUploadedIds((current) => ({ ...current, [documentType]: undefined })) + delete uploadRequestIds.current[documentType] + responseRequestId.current = null + const input = fileInputRefs.current[documentType] + if (input) input.value = '' } async function handleSubmit() { - if (!token || !file || submitting) return + if (!token || !data || submitting) return + const requestedTypes = [...new Set(data.requested_document_types)] + if (requestedTypes.length === 0 || requestedTypes.some((type) => !files[type])) { + setSubmissionError('요청받은 서류의 파일을 모두 선택해 주세요.') + return + } setSubmitting(true) - setFileError(null) - const uploadRequestId = crypto.randomUUID() + setSubmissionError(null) try { - const upload = await uploadWorkerLinkDocument(token, file, uploadRequestId) + const nextUploadedIds = { ...uploadedIds } + for (const documentType of requestedTypes) { + if (nextUploadedIds[documentType]) continue + const file = files[documentType] + if (!file) throw new Error('missing selected file') + const requestId = uploadRequestIds.current[documentType] ?? crypto.randomUUID() + uploadRequestIds.current[documentType] = requestId + const upload = await uploadWorkerLinkDocument( + token, + file, + requestId, + documentType, + ) + nextUploadedIds[documentType] = upload.upload_id + setUploadedIds({ ...nextUploadedIds }) + } + const idempotencyKey = responseRequestId.current ?? crypto.randomUUID() + responseRequestId.current = idempotencyKey const result = await submitWorkerResponse(token, { response_type: 'DOCUMENT_SUBMITTED', - upload_ids: [upload.upload_id], - idempotency_key: crypto.randomUUID(), + upload_ids: requestedTypes.map((type) => nextUploadedIds[type]!), + idempotency_key: idempotencyKey, }) setSubmission(result) } catch (caught) { @@ -86,7 +127,7 @@ export function LinkUploadPage() { navigate(`/worker-portal/${encodeURIComponent(token)}/expired`, { replace: true }) return } - setFileError( + setSubmissionError( caught instanceof ApiError ? getErrorMessage(caught) : '파일을 제출하지 못했습니다.', ) } finally { @@ -97,7 +138,7 @@ export function LinkUploadPage() { async function handleHelpResponse(responseType: WorkerResponseType, successMessage: string) { if (!token || submitting) return setSubmitting(true) - setFileError(null) + setSubmissionError(null) try { await submitWorkerResponse(token, { response_type: responseType, @@ -105,7 +146,11 @@ export function LinkUploadPage() { }) setResponseMessage(successMessage) } catch (caught) { - setFileError( + if (caught instanceof ApiError && caught.status === 410) { + navigate(`/worker-portal/${encodeURIComponent(token)}/expired`, { replace: true }) + return + } + setSubmissionError( caught instanceof ApiError ? getErrorMessage(caught) : '응답을 보내지 못했습니다.', ) } finally { @@ -138,13 +183,19 @@ export function LinkUploadPage() { } if (status === 'error' || !data) { + const contentNotReady = + error instanceof ApiError && error.code === 'WORKER_LINK_CONTENT_NOT_READY' return ( navigate(-1)}> files[type]).length + const allRequestedFilesSelected = + requestedTypes.length > 0 && selectedCount === requestedTypes.length const helpResponses: Array<{ label: string; type: WorkerResponseType; message: string }> = [ { label: HELP_LINKS[0], type: 'QUESTION', message: '담당자에게 질문 의사를 전했습니다.' }, { @@ -182,54 +237,87 @@ export function LinkUploadPage() { ] return ( - navigate(-1)} right={1 / 1}> + navigate(-1)} + right={{selectedCount} / {requestedTypes.length}} + >

- 사진 또는 파일을 + 요청받은 서류를
추가해 주세요

- 여권 사진면 전체가 보이고 글자가 흐리지 않은지 확인해 주세요. + 각 서류의 글자와 사진이 선명하게 보이는지 확인해 주세요.

- - +
+ {requestedTypes.map((documentType) => { + const file = files[documentType] + const documentLabel = DOCUMENT_TYPE_LABEL[documentType] + return ( +
+
+

{documentLabel}

+ {file ? '선택 완료' : '파일 필요'} +
+ + { + if (element) fileInputRefs.current[documentType] = element + }} + className={styles.fileInput} + type="file" + accept=".jpg,.jpeg,.png,.webp,.pdf,image/jpeg,image/png,image/webp,application/pdf" + aria-label={`${documentLabel} 제출할 파일 선택`} + disabled={submitting} + onChange={(event) => handleFileChange(documentType, event)} + /> + + {fileErrors[documentType] && ( +

+ {fileErrors[documentType]} +

+ )} + + {file && ( +
+
+

{file.name}

+

{formatFileSize(file.size)} · 제출 전 확인

+
+ +
+ )} +
+ ) + })} +
- {fileError && ( + {submissionError && (

- {fileError} + {submissionError}

)} {responseMessage &&

{responseMessage}

} - {file && ( -
-
-

{file.name}

-

{formatFileSize(file.size)} · 제출 전 확인

-
- -
- )} -

제출이 어렵다면

{helpResponses.map((response, index) => ( @@ -249,7 +337,7 @@ export function LinkUploadPage() {
)} - {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]} + ))} +
+ + + + + +