From af8aa5b035adb27c2ffc6c616e8447dc38f453d1 Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 29 Jul 2026 16:18:49 +0900 Subject: [PATCH] =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=ED=95=A8/=EA=B7=BC=EB=A1=9C=EC=9E=90=20=EC=84=9C=EB=A5=98=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20Document=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocumentListPage/DocumentDetailPage/WorkerDetailPage 서류 섹션이 demo 데이터 대신 fowoco/server 통합 문서함 API를 쓰도록 교체 (server#57). - DocumentListPage: GET /documents, 검색 파라미터가 없어 클라이언트 필터링, 탭 카운트도 받아온 페이지 기준으로 계산 - DocumentDetailPage: 단건 조회 API가 없어 전체 목록에서 worker_document_id로 탐색. 근로자 링크는 실제 worker_id로 연결 (기존 이름 매칭 제거) - WorkerDetailPage 서류 섹션: GET /documents?workerId=로 실제 연동 - 이제 완전히 안 쓰는 workerListData.ts/documentListData.ts 삭제 DocumentItemResponse에 version 필드가 없어 PATCH(확인완료/반려)는 안전하게 호출할 수 없어 화면 상태로만 남김. POST /files는 image/pdf만 허용해서 HWP/HWPX 업로드 데모(#158)는 그대로 유지. Closes #174 --- src/api/documents.test.ts | 37 ++ src/api/documents.ts | 44 ++ .../DocumentDetailPage.module.css | 4 + .../DocumentDetailPage.test.tsx | 93 +++- .../DocumentDetailPage/DocumentDetailPage.tsx | 106 +++-- .../DocumentListPage.test.tsx | 110 ++++- .../DocumentListPage/DocumentListPage.tsx | 87 ++-- .../DocumentListPage/documentListData.ts | 78 ---- .../WorkerDetailPage.test.tsx | 49 ++- .../WorkerDetailPage/WorkerDetailPage.tsx | 20 +- src/pages/WorkerListPage/workerListData.ts | 409 ------------------ src/utils/documentLabels.ts | 21 + 12 files changed, 435 insertions(+), 623 deletions(-) create mode 100644 src/api/documents.test.ts create mode 100644 src/api/documents.ts delete mode 100644 src/pages/DocumentListPage/documentListData.ts delete mode 100644 src/pages/WorkerListPage/workerListData.ts create mode 100644 src/utils/documentLabels.ts diff --git a/src/api/documents.test.ts b/src/api/documents.test.ts new file mode 100644 index 0000000..9aa4c3c --- /dev/null +++ b/src/api/documents.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchDocuments } from './documents' + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }) +} + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('fetchDocuments', () => { + it('requests /documents with default pagination and no filters', async () => { + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ items: [], page: 0, size: 100, total_elements: 0 })) + + await fetchDocuments() + + const [url] = vi.mocked(fetch).mock.calls[0] + expect(url).toContain('/documents?page=0&size=100') + }) + + it('adds filters when given', async () => { + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ items: [], page: 0, size: 20, total_elements: 0 })) + + await fetchDocuments({ workerId: 'W-1', documentType: 'PASSPORT_COPY', status: 'MISSING', page: 1, size: 20 }) + + const [url] = vi.mocked(fetch).mock.calls[0] + expect(url).toContain('workerId=W-1') + expect(url).toContain('documentType=PASSPORT_COPY') + expect(url).toContain('status=MISSING') + expect(url).toContain('page=1&size=20') + }) +}) diff --git a/src/api/documents.ts b/src/api/documents.ts new file mode 100644 index 0000000..d0e189d --- /dev/null +++ b/src/api/documents.ts @@ -0,0 +1,44 @@ +import { apiFetch } from './client' + +// fowoco/server DocumentController 기준 (#57 통합 문서함·파일 업로드·문서 준비도 구현). +export type DocumentType = 'PASSPORT_COPY' | 'ARC' | 'CONTRACT' | 'PERMIT' +export type SubmissionStatus = 'MISSING' | 'SUBMITTED' | 'VERIFIED' + +export interface DocumentItemResponse { + worker_document_id: string + worker_id: string + display_name: string | null + document_type: DocumentType + submission_status: SubmissionStatus + expiry_date: string | null + file_id: string | null +} + +export interface DocumentPageResponse { + items: DocumentItemResponse[] + page: number + size: number + total_elements: number +} + +export interface FetchDocumentsParams { + workerId?: string + documentType?: DocumentType + status?: SubmissionStatus + expiryBefore?: string + page?: number + size?: number +} + +// GET /api/v1/documents에는 자유 텍스트 검색 파라미터가 없다 — 목록 화면의 검색창은 +// 받아온 페이지 안에서만 클라이언트 필터링한다. +export function fetchDocuments(params: FetchDocumentsParams = {}): Promise { + const query = new URLSearchParams() + if (params.workerId) query.set('workerId', params.workerId) + if (params.documentType) query.set('documentType', params.documentType) + if (params.status) query.set('status', params.status) + if (params.expiryBefore) query.set('expiryBefore', params.expiryBefore) + query.set('page', String(params.page ?? 0)) + query.set('size', String(params.size ?? 100)) + return apiFetch(`/documents?${query.toString()}`) +} diff --git a/src/pages/DocumentDetailPage/DocumentDetailPage.module.css b/src/pages/DocumentDetailPage/DocumentDetailPage.module.css index 0ebef3f..ebe8c97 100644 --- a/src/pages/DocumentDetailPage/DocumentDetailPage.module.css +++ b/src/pages/DocumentDetailPage/DocumentDetailPage.module.css @@ -1,3 +1,7 @@ +.stateWrap { + margin-top: 24px; +} + .topBar { display: flex; align-items: center; diff --git a/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx b/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx index 354ba6b..567cd2c 100644 --- a/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx +++ b/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx @@ -1,9 +1,42 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router-dom' -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DocumentItemResponse, DocumentPageResponse } from '../../api/documents' import { DocumentDetailPage } from './DocumentDetailPage' -import { DOCUMENTS } from '../DocumentListPage/documentListData' + +function document(overrides: Partial): DocumentItemResponse { + return { + worker_document_id: 'D-1', + worker_id: 'W-1', + display_name: '응웬반A', + document_type: 'ARC', + submission_status: 'MISSING', + expiry_date: null, + file_id: null, + ...overrides, + } +} + +const DOCUMENTS: DocumentItemResponse[] = [ + document({ worker_document_id: 'D-1', worker_id: 'W-1', display_name: '응웬반A' }), + document({ worker_document_id: 'D-2', worker_id: 'W-2', display_name: '박서준', document_type: 'PERMIT' }), +] + +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' }, ...init }) +} + +function errorResponse(status: number, code: string, message: string) { + return jsonResponse( + { timestamp: '2026-07-27T01:23:45Z', status, code, message, path: '/api/v1/documents', request_id: 'req-1', field_errors: [] }, + { status }, + ) +} + +function pageResponse(items: DocumentItemResponse[]): DocumentPageResponse { + return { items, page: 0, size: 100, total_elements: items.length } +} function renderPage(documentId: string) { render( @@ -12,43 +45,50 @@ function renderPage(documentId: string) { } /> 서류 목록

} /> 근로자 상세

} /> - 업무 상세

} /> , ) } +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + describe('DocumentDetailPage', () => { - it('renders the document type and worker name', () => { - const document = DOCUMENTS[0] - renderPage(document.id) + it('renders the document type and worker name', async () => { + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS))) + renderPage('D-1') - expect(screen.getByRole('heading', { name: document.docType })).toBeInTheDocument() - expect(screen.getAllByText(document.workerName, { exact: false }).length).toBeGreaterThan(0) - expect(screen.getByText(`${document.docType}.pdf`)).toBeInTheDocument() + expect(await screen.findByRole('heading', { name: '외국인등록증' })).toBeInTheDocument() + expect(screen.getAllByText(/응웬반A/).length).toBeGreaterThan(0) }) - it('navigates to the related worker and task', async () => { + it('navigates to the related worker', async () => { const user = userEvent.setup() - const document = DOCUMENTS.find((item) => item.workerName === '응웬반A')! - renderPage(document.id) + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS))) + renderPage('D-1') - await user.click(screen.getByRole('button', { name: '응웬반A 근로자 정보 →' })) + await user.click(await screen.findByRole('button', { name: '응웬반A 정보 →' })) expect(await screen.findByText('근로자 상세')).toBeInTheDocument() }) - it('shows an empty state when there is no matching worker', () => { - const document = DOCUMENTS.find((item) => item.workerName === '박서준')! - renderPage(document.id) + it('shows an empty state when the documentId does not match any document', async () => { + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS))) + renderPage('does-not-exist') - expect(screen.getByText('연결된 근로자를 찾을 수 없습니다')).toBeInTheDocument() + expect(await screen.findByText('서류를 찾을 수 없습니다')).toBeInTheDocument() }) - it('approves and rejects the document, toggling status', async () => { + it('approves and rejects the document locally, toggling status', async () => { const user = userEvent.setup() - const document = DOCUMENTS[0] - renderPage(document.id) + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS))) + renderPage('D-1') + await screen.findByRole('heading', { name: '외국인등록증' }) await user.click(screen.getByRole('button', { name: '확인 완료 처리' })) expect(screen.getByText('확인 완료')).toBeInTheDocument() @@ -57,9 +97,16 @@ describe('DocumentDetailPage', () => { expect(screen.getByText('미제출')).toBeInTheDocument() }) - it('falls back to the first document when the documentId is invalid', () => { - renderPage('does-not-exist') + it('shows a loading state', () => { + vi.mocked(fetch).mockReturnValue(new Promise(() => {})) + renderPage('D-1') + expect(screen.getByText('서류 정보를 불러오는 중입니다')).toBeInTheDocument() + }) + + it('shows an error state with a retry action', async () => { + vi.mocked(fetch).mockResolvedValueOnce(errorResponse(500, 'INTERNAL_SERVER_ERROR', 'raw')) + renderPage('D-1') - expect(screen.getByRole('heading', { name: DOCUMENTS[0].docType })).toBeInTheDocument() + expect(await screen.findByRole('button', { name: '다시 시도' })).toBeInTheDocument() }) }) diff --git a/src/pages/DocumentDetailPage/DocumentDetailPage.tsx b/src/pages/DocumentDetailPage/DocumentDetailPage.tsx index 5ee0a12..1b273e6 100644 --- a/src/pages/DocumentDetailPage/DocumentDetailPage.tsx +++ b/src/pages/DocumentDetailPage/DocumentDetailPage.tsx @@ -1,11 +1,13 @@ -import { useState } from 'react' +import { useCallback, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' +import { fetchDocuments, type SubmissionStatus } from '../../api/documents' +import { getErrorMessage } from '../../api/errors' import { Button } from '../../components/ui/Button/Button' import { EmptyState } from '../../components/ui/EmptyState/EmptyState' import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel' +import { useApiQuery } from '../../hooks/useApiQuery' import { useToastStore } from '../../store/toastStore' -import { DOCUMENT_STATUS_LABEL, DOCUMENT_STATUS_TONE, DOCUMENTS, type DocumentStatus } from '../DocumentListPage/documentListData' -import { WORKERS } from '../WorkerListPage/workerListData' +import { DOCUMENT_TYPE_LABEL, SUBMISSION_STATUS_LABEL, SUBMISSION_STATUS_TONE } from '../../utils/documentLabels' import styles from './DocumentDetailPage.module.css' export function DocumentDetailPage() { @@ -13,22 +15,55 @@ export function DocumentDetailPage() { const navigate = useNavigate() const showToast = useToastStore((state) => state.showToast) - const document = DOCUMENTS.find((item) => item.id === documentId) ?? DOCUMENTS[0] - const [status, setStatus] = useState(document.status) + // GET /api/v1/documents/{id} 단건 조회가 없어서(#57 조사 결과), 목록을 통째로 받아 + // worker_document_id로 찾는다. + const { status: fetchStatus, data, error, refetch } = useApiQuery(useCallback(() => fetchDocuments({ size: 100 }), [])) + const document = data?.items.find((item) => item.worker_document_id === documentId) ?? null - // TODO(backend): GET /api/documents/:id -> 근로자·업무 연결 정보를 서버에서 직접 내려받도록 대체 - const relatedWorker = WORKERS.find((worker) => worker.name === document.workerName) - const relatedCaseId = relatedWorker?.currentTasks[0]?.caseId ?? null + const [localStatus, setLocalStatus] = useState(null) + if (fetchStatus === 'loading') { + return ( +
+ +
+ ) + } + + if (fetchStatus === 'error') { + return ( +
+ +
+ ) + } + + if (!document) { + return ( +
+ +
+ ) + } + + const status = localStatus ?? document.submission_status + + // TODO(backend): PATCH /api/v1/workers/{workerId}/documents/{id}에는 expected_version이 + // 필요한데, 목록 응답(DocumentItemResponse)에 version 필드가 없어 안전하게 호출할 수 + // 없다 (#57 조사 결과 — 서버에 문의 필요). 그때까지 확인/반려는 화면에서만 반영한다. function handleApprove() { - // TODO(backend): PATCH /api/documents/:id { status: 'done' } - setStatus('done') + setLocalStatus('VERIFIED') showToast('서류를 확인 완료 처리했습니다.') } function handleReject() { - // TODO(backend): PATCH /api/documents/:id { status: 'missing' } -> 근로자에게 재제출 안내 발송 - setStatus('missing') + setLocalStatus('MISSING') showToast('서류를 반려했습니다. 근로자에게 재제출을 요청하세요.') } @@ -41,53 +76,40 @@ export function DocumentDetailPage() {
-

{document.docType}

- {DOCUMENT_STATUS_LABEL[status]} +

{DOCUMENT_TYPE_LABEL[document.document_type]}

+ {SUBMISSION_STATUS_LABEL[status]}

- {document.workerName} · 제출일 {document.submittedAt} + {document.display_name ?? '알 수 없음'} · 만료일 {document.expiry_date ?? '없음'}

첨부 미리보기

- {/* TODO(backend): GET /api/documents/:id/file -> 실제 첨부 파일 미리보기로 대체 */} + {/* TODO(backend): file_id로 실제 파일을 내려받는 API가 아직 없음 */}
-

{document.docType}.pdf

+

{DOCUMENT_TYPE_LABEL[document.document_type]}

미리보기는 백엔드 연동 후 제공됩니다.

-

관련 근로자·업무

- {relatedWorker ? ( -
- - {relatedCaseId && ( - - )} -
- ) : ( - - )} +

관련 근로자

+
+ +
- -
diff --git a/src/pages/DocumentListPage/DocumentListPage.test.tsx b/src/pages/DocumentListPage/DocumentListPage.test.tsx index a717154..3981d5a 100644 --- a/src/pages/DocumentListPage/DocumentListPage.test.tsx +++ b/src/pages/DocumentListPage/DocumentListPage.test.tsx @@ -1,13 +1,59 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router-dom' -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DocumentItemResponse, DocumentPageResponse } from '../../api/documents' import { DocumentListPage } from './DocumentListPage' -import { DOCUMENT_TABS, DOCUMENTS } from './documentListData' -function renderPage(demoState = 'success') { +function document(overrides: Partial): DocumentItemResponse { + return { + worker_document_id: 'D-1', + worker_id: 'W-1', + display_name: '수라즈C', + document_type: 'ARC', + submission_status: 'MISSING', + expiry_date: null, + file_id: null, + ...overrides, + } +} + +const DOCUMENTS: DocumentItemResponse[] = [ + document({ worker_document_id: 'D-1', display_name: '수라즈C', document_type: 'ARC', submission_status: 'MISSING' }), + document({ + worker_document_id: 'D-2', + display_name: '쩐티B', + document_type: 'CONTRACT', + submission_status: 'SUBMITTED', + expiry_date: '2027-07-18', + }), + document({ + worker_document_id: 'D-3', + display_name: '박서준', + document_type: 'PERMIT', + submission_status: 'VERIFIED', + expiry_date: '2027-07-10', + }), +] + +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' }, ...init }) +} + +function errorResponse(status: number, code: string, message: string) { + return jsonResponse( + { timestamp: '2026-07-27T01:23:45Z', status, code, message, path: '/api/v1/documents', request_id: 'req-1', field_errors: [] }, + { status }, + ) +} + +function pageResponse(items: DocumentItemResponse[]): DocumentPageResponse { + return { items, page: 0, size: 100, total_elements: items.length } +} + +function renderPage() { render( - + } /> 서류 상세

} /> @@ -16,23 +62,35 @@ function renderPage(demoState = 'success') { ) } +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + describe('DocumentListPage', () => { - it('renders every tab and document row', () => { + it('renders every tab and document row', async () => { + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS))) renderPage() - for (const tab of DOCUMENT_TABS) { - expect(screen.getByRole('tab', { name: `${tab.label} ${tab.count}` })).toBeInTheDocument() - } - for (const document of DOCUMENTS) { - expect(screen.getByText(document.workerName)).toBeInTheDocument() - } + expect(await screen.findByText('수라즈C')).toBeInTheDocument() + expect(screen.getByText('쩐티B')).toBeInTheDocument() + expect(screen.getByText('박서준')).toBeInTheDocument() + expect(screen.getByRole('tab', { name: '전체 3' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: '미제출 1' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: '확인 대기 1' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: '확인 완료 1' })).toBeInTheDocument() }) it('filters documents by search query', async () => { const user = userEvent.setup() + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS))) renderPage() - await user.type(screen.getByLabelText('서류 검색'), '표준근로계약서') + await screen.findByLabelText('서류 검색') + await user.type(screen.getByLabelText('서류 검색'), '근로계약서') await waitFor(() => { expect(screen.queryByText('수라즈C')).not.toBeInTheDocument() @@ -42,9 +100,10 @@ describe('DocumentListPage', () => { it('filters documents by tab', async () => { const user = userEvent.setup() + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS))) renderPage() - await user.click(screen.getByRole('tab', { name: '미제출 5' })) + await user.click(await screen.findByRole('tab', { name: '미제출 1' })) expect(screen.getByText('수라즈C')).toBeInTheDocument() expect(screen.queryByText('박서준')).not.toBeInTheDocument() @@ -52,35 +111,50 @@ describe('DocumentListPage', () => { it('navigates to the document detail when "확인하기 →" is clicked', async () => { const user = userEvent.setup() + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS))) renderPage() - await user.click(screen.getAllByRole('button', { name: '확인하기 →' })[0]) + await user.click((await screen.findAllByRole('button', { name: '확인하기 →' }))[0]) expect(await screen.findByText('서류 상세')).toBeInTheDocument() }) it('shows an empty state when a search has no matches', async () => { const user = userEvent.setup() + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS))) renderPage() + await screen.findByLabelText('서류 검색') await user.type(screen.getByLabelText('서류 검색'), '존재하지않는검색어') expect(await screen.findByText('검색 결과가 없습니다')).toBeInTheDocument() }) it('shows a loading state', () => { - renderPage('loading') + vi.mocked(fetch).mockReturnValue(new Promise(() => {})) + renderPage() expect(screen.getByText('서류 목록을 불러오는 중입니다')).toBeInTheDocument() }) - it('shows an error state', () => { - renderPage('error') - expect(screen.getByText('서류 목록을 불러오지 못했습니다')).toBeInTheDocument() + it('shows an error state with a retry action', async () => { + vi.mocked(fetch).mockResolvedValueOnce(errorResponse(500, 'INTERNAL_SERVER_ERROR', 'raw')) + renderPage() + + expect(await screen.findByRole('button', { name: '다시 시도' })).toBeInTheDocument() + }) + + it('shows an empty state when there are no documents', async () => { + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse([]))) + renderPage() + + expect(await screen.findByText('등록된 서류가 없습니다')).toBeInTheDocument() }) it('opens the HWP/HWPX upload modal', async () => { const user = userEvent.setup() + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS))) renderPage() + await screen.findByText('수라즈C') await user.click(screen.getByRole('button', { name: '+ HWP/HWPX 업로드' })) diff --git a/src/pages/DocumentListPage/DocumentListPage.tsx b/src/pages/DocumentListPage/DocumentListPage.tsx index cccb4be..ad96311 100644 --- a/src/pages/DocumentListPage/DocumentListPage.tsx +++ b/src/pages/DocumentListPage/DocumentListPage.tsx @@ -1,54 +1,63 @@ -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' +import { fetchDocuments, type SubmissionStatus } from '../../api/documents' +import { getErrorMessage } from '../../api/errors' import { EmptyState } from '../../components/ui/EmptyState/EmptyState' import { ListRow } from '../../components/ui/ListRow/ListRow' import { SearchInput } from '../../components/ui/SearchInput/SearchInput' import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel' import { Tabs } from '../../components/ui/Tabs/Tabs' -import { useAsyncDemoData } from '../../hooks/useAsyncDemoData' +import { useApiQuery } from '../../hooks/useApiQuery' import { useDebouncedValue } from '../../hooks/useDebouncedValue' +import { DOCUMENT_TYPE_LABEL, SUBMISSION_STATUS_LABEL, SUBMISSION_STATUS_TONE } from '../../utils/documentLabels' import styles from './DocumentListPage.module.css' -import { - DOCUMENT_STATUS_LABEL, - DOCUMENT_STATUS_TONE, - DOCUMENT_TABS, - DOCUMENTS, - TOTAL_DOCUMENT_COUNT, - type DocumentStatus, -} from './documentListData' import { FileUploadModal } from './FileUploadModal' -const TAB_STATUS: Record = { - all: null, - missing: 'missing', - pending: 'pending', - done: 'done', -} +type TabId = 'all' | SubmissionStatus + +const DOCUMENT_TABS: { id: TabId; label: string }[] = [ + { id: 'all', label: '전체' }, + { id: 'MISSING', label: '미제출' }, + { id: 'SUBMITTED', label: '확인 대기' }, + { id: 'VERIFIED', label: '확인 완료' }, +] export function DocumentListPage() { const navigate = useNavigate() - const status = useAsyncDemoData(DOCUMENTS.length === 0) - const [activeTab, setActiveTab] = useState(DOCUMENT_TABS[0].id) + const [activeTab, setActiveTab] = useState('all') const [query, setQuery] = useState('') const [uploadModalOpen, setUploadModalOpen] = useState(false) const debouncedQuery = useDebouncedValue(query) + const { status, data, error, refetch } = useApiQuery( + useCallback(() => fetchDocuments({ size: 100 }), []), + useCallback((page: { items: unknown[] }) => page.items.length === 0, []), + ) + const documents = useMemo(() => data?.items ?? [], [data]) + + const tabsWithCounts = useMemo( + () => + DOCUMENT_TABS.map((tab) => ({ + ...tab, + count: tab.id === 'all' ? documents.length : documents.filter((doc) => doc.submission_status === tab.id).length, + })), + [documents], + ) + const visibleDocuments = useMemo(() => { - const tabStatus = TAB_STATUS[activeTab] const normalized = debouncedQuery.trim().toLowerCase() - - return DOCUMENTS.filter((document) => { - const matchesTab = tabStatus === null || document.status === tabStatus + return documents.filter((document) => { + const matchesTab = activeTab === 'all' || document.submission_status === activeTab const matchesQuery = !normalized || - document.workerName.toLowerCase().includes(normalized) || - document.docType.toLowerCase().includes(normalized) + (document.display_name ?? '').toLowerCase().includes(normalized) || + DOCUMENT_TYPE_LABEL[document.document_type].toLowerCase().includes(normalized) return matchesTab && matchesQuery }) - }, [activeTab, debouncedQuery]) + }, [documents, activeTab, debouncedQuery]) - function handleReviewDocument(documentId: string) { - navigate(`/documents/${documentId}`) + function handleReviewDocument(workerDocumentId: string) { + navigate(`/documents/${workerDocumentId}`) } return ( @@ -58,7 +67,7 @@ export function DocumentListPage() { 미제출·확인 대기 서류를 우선 보여주며, 확인이 끝나면 상태가 자동으로 갱신됩니다.

- + setActiveTab(id as TabId)} ariaLabel="서류 탭" />
)} @@ -101,7 +112,7 @@ export function DocumentListPage() {
근로자 · 서류 종류 상태 - 제출일 + 만료일
@@ -112,19 +123,19 @@ export function DocumentListPage() { ) : (
{visibleDocuments.map((document) => ( - +
-

{document.workerName}

-

{document.docType}

+

{document.display_name ?? '알 수 없음'}

+

{DOCUMENT_TYPE_LABEL[document.document_type]}

- - {DOCUMENT_STATUS_LABEL[document.status]} + + {SUBMISSION_STATUS_LABEL[document.submission_status]} - {document.submittedAt} + {document.expiry_date ?? '없음'} @@ -134,7 +145,7 @@ export function DocumentListPage() { )}

- {TOTAL_DOCUMENT_COUNT}건 중 {visibleDocuments.length}건 표시 + {data?.total_elements ?? documents.length}건 중 {visibleDocuments.length}건 표시

)} diff --git a/src/pages/DocumentListPage/documentListData.ts b/src/pages/DocumentListPage/documentListData.ts deleted file mode 100644 index 3997f43..0000000 --- a/src/pages/DocumentListPage/documentListData.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { StatusTone } from '../../components/ui/StatusLabel/StatusLabel' - -// TODO(backend): GET /api/documents?tab=&q= -> DOCUMENT_TABS, DOCUMENTS 대체 - -export interface DocumentTab { - id: string - label: string - count: number -} - -export const DOCUMENT_TABS: DocumentTab[] = [ - { id: 'all', label: '전체', count: 18 }, - { id: 'missing', label: '미제출', count: 5 }, - { id: 'pending', label: '확인 대기', count: 4 }, - { id: 'done', label: '확인 완료', count: 9 }, -] - -export type DocumentStatus = 'missing' | 'pending' | 'done' - -export const DOCUMENT_STATUS_TONE: Record = { - missing: 'critical', - pending: 'warning', - done: 'success', -} - -export const DOCUMENT_STATUS_LABEL: Record = { - missing: '미제출', - pending: '확인 대기', - done: '확인 완료', -} - -export interface DocumentItem { - id: string - workerName: string - docType: string - status: DocumentStatus - submittedAt: string -} - -export const DOCUMENTS: DocumentItem[] = [ - { - id: 'DOC-1', - workerName: '수라즈C', - docType: '외국인등록증 사본', - status: 'missing', - submittedAt: '미제출', - }, - { - id: 'DOC-2', - workerName: '쩐티B', - docType: '표준근로계약서', - status: 'pending', - submittedAt: '2026-07-18', - }, - { - id: 'DOC-3', - workerName: '아흐메드D', - docType: '건강진단서', - status: 'missing', - submittedAt: '미제출', - }, - { - id: 'DOC-4', - workerName: '응웬반A', - docType: '체류연장 신청서', - status: 'pending', - submittedAt: '2026-07-19', - }, - { - id: 'DOC-5', - workerName: '박서준', - docType: '숙소 임대차 계약서', - status: 'done', - submittedAt: '2026-07-10', - }, -] - -export const TOTAL_DOCUMENT_COUNT = 18 diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx b/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx index ad4f230..c7ee505 100644 --- a/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx +++ b/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DocumentItemResponse } from '../../api/documents' import type { WorkerResponse } from '../../api/workers' import { WorkerDetailPage } from './WorkerDetailPage' @@ -37,6 +38,39 @@ function worker(overrides: Partial = {}): WorkerResponse { } } +function document(overrides: Partial = {}): DocumentItemResponse { + return { + worker_document_id: 'D-1', + worker_id: 'W-018', + display_name: '쩐티B', + document_type: 'CONTRACT', + submission_status: 'SUBMITTED', + expiry_date: '2027-07-18', + file_id: null, + ...overrides, + } +} + +function mockWorkerAndDocuments(workerOverrides: Partial = {}, documents: DocumentItemResponse[] = []) { + vi.mocked(fetch).mockImplementation((input) => { + const url = String(input) + if (url.includes('/documents')) { + return Promise.resolve(jsonResponse({ items: documents, page: 0, size: 100, total_elements: documents.length })) + } + return Promise.resolve(jsonResponse(worker(workerOverrides))) + }) +} + +function mockWorkerError(status: number, code: string, message: string) { + vi.mocked(fetch).mockImplementation((input) => { + const url = String(input) + if (url.includes('/documents')) { + return Promise.resolve(jsonResponse({ items: [], page: 0, size: 100, total_elements: 0 })) + } + return Promise.resolve(errorResponse(status, code, message)) + }) +} + function renderPage(workerId: string) { render( @@ -58,7 +92,7 @@ afterEach(() => { describe('WorkerDetailPage', () => { it('renders basic profile info for the selected worker', async () => { - vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(worker())) + mockWorkerAndDocuments() renderPage('W-018') expect(await screen.findByRole('heading', { name: '쩐티B' })).toBeInTheDocument() @@ -66,15 +100,16 @@ describe('WorkerDetailPage', () => { expect(screen.getAllByText('준비 중').length).toBeGreaterThan(0) }) - it('shows documents matched by worker name', async () => { - vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(worker({ display_name: '쩐티B' }))) + it("shows the worker's real documents", async () => { + mockWorkerAndDocuments({ display_name: '쩐티B' }, [document()]) renderPage('W-018') - expect(await screen.findByText('표준근로계약서')).toBeInTheDocument() + expect(await screen.findByText('근로계약서')).toBeInTheDocument() + expect(screen.getByText('확인 대기')).toBeInTheDocument() }) - it('shows an empty state when the worker has no matching documents', async () => { - vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(worker({ display_name: '이름없음' }))) + it('shows an empty state when the worker has no documents', async () => { + mockWorkerAndDocuments({ display_name: '이름없음' }, []) renderPage('W-999') expect(await screen.findByText('제출된 서류가 없습니다')).toBeInTheDocument() @@ -88,7 +123,7 @@ describe('WorkerDetailPage', () => { }) it('shows an error state with a retry action', async () => { - vi.mocked(fetch).mockResolvedValueOnce(errorResponse(404, 'RESOURCE_NOT_FOUND', 'raw')) + mockWorkerError(404, 'RESOURCE_NOT_FOUND', 'raw') renderPage('does-not-exist') expect(await screen.findByRole('button', { name: '다시 시도' })).toBeInTheDocument() diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.tsx b/src/pages/WorkerDetailPage/WorkerDetailPage.tsx index 3d559bf..3a1725d 100644 --- a/src/pages/WorkerDetailPage/WorkerDetailPage.tsx +++ b/src/pages/WorkerDetailPage/WorkerDetailPage.tsx @@ -1,12 +1,13 @@ import { useCallback } from 'react' import { Link, useParams } from 'react-router-dom' +import { fetchDocuments } from '../../api/documents' import { fetchWorkerById } from '../../api/workers' import { getErrorMessage } from '../../api/errors' import { DetailRow } from '../../components/ui/DetailRow/DetailRow' import { EmptyState } from '../../components/ui/EmptyState/EmptyState' import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel' import { useApiQuery } from '../../hooks/useApiQuery' -import { DOCUMENT_STATUS_LABEL, DOCUMENT_STATUS_TONE, DOCUMENTS } from '../DocumentListPage/documentListData' +import { DOCUMENT_TYPE_LABEL, SUBMISSION_STATUS_LABEL, SUBMISSION_STATUS_TONE } from '../../utils/documentLabels' import { daysUntil, getUrgencyTier, URGENCY_TONE } from '../../utils/urgency' import styles from './WorkerDetailPage.module.css' @@ -19,6 +20,11 @@ export function WorkerDetailPage() { const fetcher = useCallback(() => fetchWorkerById(workerId ?? ''), [workerId]) const { status, data: worker, error, refetch } = useApiQuery(fetcher) + const { data: documentPage } = useApiQuery( + useCallback(() => fetchDocuments({ workerId: workerId ?? '', size: 100 }), [workerId]), + ) + const workerDocuments = documentPage?.items ?? [] + if (status === 'loading') { return (
@@ -44,8 +50,6 @@ export function WorkerDetailPage() { const deadlineDays = daysUntil(worker.stay_expiry_date) const deadlineLabel = deadlineDays === null ? '정상' : `D-${deadlineDays} 체류만료` const deadlineTier = getUrgencyTier(deadlineDays) - // TODO(backend): GET /api/documents?workerId= -> 근로자 기준 서류 목록으로 대체 (현재는 이름으로 매칭) - const workerDocuments = DOCUMENTS.filter((document) => document.workerName === worker.display_name) return (
@@ -86,12 +90,12 @@ export function WorkerDetailPage() { ) : (
{workerDocuments.map((document) => ( -
- {document.docType} - - {DOCUMENT_STATUS_LABEL[document.status]} +
+ {DOCUMENT_TYPE_LABEL[document.document_type]} + + {SUBMISSION_STATUS_LABEL[document.submission_status]} - {document.submittedAt} + {document.expiry_date ?? '없음'}
))}
diff --git a/src/pages/WorkerListPage/workerListData.ts b/src/pages/WorkerListPage/workerListData.ts deleted file mode 100644 index bd9d212..0000000 --- a/src/pages/WorkerListPage/workerListData.ts +++ /dev/null @@ -1,409 +0,0 @@ -// TODO(backend): GET /api/workers?deadline=90d&q= -> WORKERS 대체 -// 백엔드 연동 전까지는 deadlineDays 기준으로 클라이언트에서 직접 필터링한다. -// TODO(backend): GET /api/workers/:id -> 선택된 근로자의 currentTasks/timeline 대체 - -export interface WorkerTask { - /** WorkListPage/CaseDetailPage와 동일한 업무 ID 체계 (`/tasks/:caseId`로 연결) */ - caseId: string - title: string - detail: string - linkTone: 'warning' | 'primary' -} - -export interface WorkerTimelineEntry { - date: string - label: string - highlighted: boolean -} - -export interface Worker { - id: string - name: string - nationality: string - visaType: string - employeeId: string - phone: string - deadlineLabel: string - /** null이면 임박한 기한이 없어 기한 필터 값과 무관하게 항상 표시한다. */ - deadlineDays: number | null - note: string - currentTasks: WorkerTask[] - timeline: WorkerTimelineEntry[] -} - -export const WORKERS: Worker[] = [ - { - id: 'W-021', - name: '응웬반A', - nationality: '베트남', - visaType: 'E-9', - employeeId: 'F-021', - phone: '010-****-3182', - deadlineLabel: 'D-12 체류', - deadlineDays: 12, - note: '진행 업무 2건', - currentTasks: [ - { caseId: 'WI-021', title: '체류연장 준비', detail: '승인 대기 · D-12', linkTone: 'warning' }, - { caseId: 'WI-021-2', title: '여권 사본 요청', detail: '근로자 응답 대기 · 오늘', linkTone: 'primary' }, - ], - timeline: [ - { date: '07.20', label: 'Agent가 체류연장 업무 초안 준비', highlighted: true }, - { date: '07.18', label: '외국인등록증 유효기간 확인', highlighted: false }, - { date: '07.02', label: '7월 급여명세 안내 확인', highlighted: false }, - { date: '06.14', label: '기숙사 교육 완료', highlighted: false }, - ], - }, - { - id: 'W-018', - name: '쩐티B', - nationality: '베트남', - visaType: 'E-9', - employeeId: 'F-018', - phone: '010-****-2091', - deadlineLabel: 'D-21 서류', - deadlineDays: 21, - note: '서류 누락 1건', - currentTasks: [{ caseId: 'WI-018', title: '외국인등록증 사본 제출 요청', detail: '오늘 · 근로자 응답 대기', linkTone: 'warning' }], - timeline: [{ date: '07.19', label: '서류 미제출 안내 발송', highlighted: true }], - }, - { - id: 'W-032', - name: '수라즈C', - nationality: '네팔', - visaType: 'E-9', - employeeId: 'F-032', - phone: '010-****-7745', - deadlineLabel: 'D-35 체류', - deadlineDays: 35, - note: '응답 대기 1건', - currentTasks: [{ caseId: 'WI-032', title: '체류연장 안내 응답 대기', detail: '오늘 · 근로자 응답 대기', linkTone: 'warning' }], - timeline: [{ date: '07.15', label: '체류연장 안내 발송', highlighted: true }], - }, - { - id: 'W-014', - name: '아흐메드D', - nationality: '방글라데시', - visaType: 'E-9', - employeeId: 'F-014', - phone: '010-****-5510', - deadlineLabel: 'D-62 체류', - deadlineDays: 62, - note: '진행 업무 없음', - currentTasks: [], - timeline: [{ date: '06.28', label: '입사 서류 확인 완료', highlighted: false }], - }, - { - id: 'W-027', - name: '솜차이E', - nationality: '태국', - visaType: 'E-9', - employeeId: 'F-027', - phone: '010-****-6623', - deadlineLabel: '정상', - deadlineDays: null, - note: '교육 일정 1건', - currentTasks: [{ caseId: 'WI-027', title: '신규 교육 일정 확정', detail: 'D-4 · 담당자 미배정', linkTone: 'warning' }], - timeline: [{ date: '07.10', label: '교육 일정 등록', highlighted: false }], - }, - { - id: 'W-041', - name: '판반F', - nationality: '베트남', - visaType: 'E-9', - employeeId: 'F-041', - phone: '010-****-1123', - deadlineLabel: 'D-9 서류', - deadlineDays: 9, - note: '서류 누락 1건', - currentTasks: [{ caseId: 'WI-041', title: '표준근로계약서 갱신 확인', detail: '승인 대기 · D-9', linkTone: 'warning' }], - timeline: [{ date: '07.18', label: '계약서 갱신 요청 발송', highlighted: false }], - }, - { - id: 'W-042', - name: '치트라G', - nationality: '태국', - visaType: 'E-9', - employeeId: 'F-042', - phone: '010-****-4471', - deadlineLabel: 'D-27 체류', - deadlineDays: 27, - note: '진행 업무 1건', - currentTasks: [{ caseId: 'WI-042', title: '체류연장 서류 안내', detail: '근로자 응답 대기 · 오늘', linkTone: 'primary' }], - timeline: [{ date: '07.16', label: '체류연장 안내 발송', highlighted: false }], - }, - { - id: 'W-043', - name: '아민H', - nationality: '방글라데시', - visaType: 'E-9', - employeeId: 'F-043', - phone: '010-****-8820', - deadlineLabel: '정상', - deadlineDays: null, - note: '진행 업무 없음', - currentTasks: [], - timeline: [{ date: '07.01', label: '입사 서류 확인 완료', highlighted: false }], - }, - { - id: 'W-044', - name: '수리야I', - nationality: '네팔', - visaType: 'E-9', - employeeId: 'F-044', - phone: '010-****-2290', - deadlineLabel: 'D-45 체류', - deadlineDays: 45, - note: '진행 업무 없음', - currentTasks: [], - timeline: [{ date: '06.30', label: '체류 상태 확인 완료', highlighted: false }], - }, - { - id: 'W-045', - name: '완나J', - nationality: '태국', - visaType: 'E-9', - employeeId: 'F-045', - phone: '010-****-3345', - deadlineLabel: 'D-58 체류', - deadlineDays: 58, - note: '진행 업무 없음', - currentTasks: [], - timeline: [{ date: '06.20', label: '건강검진 완료', highlighted: false }], - }, - { - id: 'W-046', - name: '레반K', - nationality: '베트남', - visaType: 'E-9', - employeeId: 'F-046', - phone: '010-****-9012', - deadlineLabel: 'D-33 서류', - deadlineDays: 33, - note: '서류 누락 1건', - currentTasks: [{ caseId: 'WI-046', title: '건강진단서 제출 요청', detail: '오늘 · 근로자 응답 대기', linkTone: 'warning' }], - timeline: [{ date: '07.14', label: '건강진단서 제출 요청 발송', highlighted: false }], - }, - { - id: 'W-047', - name: '누르완L', - nationality: '인도네시아', - visaType: 'E-9', - employeeId: 'F-047', - phone: '010-****-6690', - deadlineLabel: '정상', - deadlineDays: null, - note: '진행 업무 없음', - currentTasks: [], - timeline: [{ date: '06.25', label: '입사 서류 확인 완료', highlighted: false }], - }, - { - id: 'W-048', - name: '위라M', - nationality: '태국', - visaType: 'E-9', - employeeId: 'F-048', - phone: '010-****-5512', - deadlineLabel: 'D-70 체류', - deadlineDays: 70, - note: '진행 업무 없음', - currentTasks: [], - timeline: [{ date: '06.18', label: '체류 상태 확인 완료', highlighted: false }], - }, - { - id: 'W-049', - name: '딴티N', - nationality: '베트남', - visaType: 'E-9', - employeeId: 'F-049', - phone: '010-****-7734', - deadlineLabel: 'D-19 체류', - deadlineDays: 19, - note: '진행 업무 1건', - currentTasks: [{ caseId: 'WI-049', title: '체류연장 준비', detail: '승인 대기 · D-19', linkTone: 'warning' }], - timeline: [{ date: '07.17', label: 'Agent가 체류연장 업무 초안 준비', highlighted: false }], - }, - { - id: 'W-050', - name: '파루크O', - nationality: '방글라데시', - visaType: 'E-9', - employeeId: 'F-050', - phone: '010-****-4423', - deadlineLabel: 'D-52 체류', - deadlineDays: 52, - note: '진행 업무 없음', - currentTasks: [], - timeline: [{ date: '06.15', label: '체류 상태 확인 완료', highlighted: false }], - }, - { - id: 'W-051', - name: '분미P', - nationality: '태국', - visaType: 'E-9', - employeeId: 'F-051', - phone: '010-****-8891', - deadlineLabel: '정상', - deadlineDays: null, - note: '교육 일정 1건', - currentTasks: [{ caseId: 'WI-051', title: '분기별 안전교육 일정 확정', detail: 'D-22 · 담당자 미배정', linkTone: 'warning' }], - timeline: [{ date: '07.05', label: '교육 일정 등록', highlighted: false }], - }, - { - id: 'W-052', - name: '도안Q', - nationality: '베트남', - visaType: 'E-9', - employeeId: 'F-052', - phone: '010-****-1156', - deadlineLabel: 'D-24 서류', - deadlineDays: 24, - note: '서류 누락 1건', - currentTasks: [{ caseId: 'WI-052', title: '외국인등록증 사본 제출 요청', detail: '오늘 · 근로자 응답 대기', linkTone: 'warning' }], - timeline: [{ date: '07.13', label: '서류 미제출 안내 발송', highlighted: false }], - }, - { - id: 'W-053', - name: '수카R', - nationality: '인도네시아', - visaType: 'E-9', - employeeId: 'F-053', - phone: '010-****-3367', - deadlineLabel: 'D-38 체류', - deadlineDays: 38, - note: '진행 업무 없음', - currentTasks: [], - timeline: [{ date: '06.22', label: '체류 상태 확인 완료', highlighted: false }], - }, - { - id: 'W-054', - name: '마야S', - nationality: '네팔', - visaType: 'E-9', - employeeId: 'F-054', - phone: '010-****-6612', - deadlineLabel: '정상', - deadlineDays: null, - note: '진행 업무 없음', - currentTasks: [], - timeline: [{ date: '06.10', label: '입사 서류 확인 완료', highlighted: false }], - }, - { - id: 'W-055', - name: '흐엉T', - nationality: '베트남', - visaType: 'E-9', - employeeId: 'F-055', - phone: '010-****-9945', - deadlineLabel: 'D-15 체류', - deadlineDays: 15, - note: '진행 업무 1건', - currentTasks: [{ caseId: 'WI-055', title: '체류연장 안내 응답 대기', detail: '오늘 · 근로자 응답 대기', linkTone: 'warning' }], - timeline: [{ date: '07.12', label: '체류연장 안내 발송', highlighted: false }], - }, - { - id: 'W-056', - name: '아난U', - nationality: '태국', - visaType: 'E-9', - employeeId: 'F-056', - phone: '010-****-2278', - deadlineLabel: 'D-64 체류', - deadlineDays: 64, - note: '진행 업무 없음', - currentTasks: [], - timeline: [{ date: '06.08', label: '체류 상태 확인 완료', highlighted: false }], - }, - { - id: 'W-057', - name: '빈랏V', - nationality: '방글라데시', - visaType: 'E-9', - employeeId: 'F-057', - phone: '010-****-5534', - deadlineLabel: '정상', - deadlineDays: null, - note: '진행 업무 없음', - currentTasks: [], - timeline: [{ date: '06.05', label: '입사 서류 확인 완료', highlighted: false }], - }, - { - id: 'W-058', - name: '리엔W', - nationality: '베트남', - visaType: 'E-9', - employeeId: 'F-058', - phone: '010-****-8867', - deadlineLabel: 'D-41 서류', - deadlineDays: 41, - note: '서류 누락 1건', - currentTasks: [{ caseId: 'WI-058', title: '건강진단서 제출 요청', detail: '오늘 · 근로자 응답 대기', linkTone: 'warning' }], - timeline: [{ date: '07.09', label: '건강진단서 제출 요청 발송', highlighted: false }], - }, - { - id: 'W-059', - name: '이만X', - nationality: '인도네시아', - visaType: 'E-9', - employeeId: 'F-059', - phone: '010-****-1189', - deadlineLabel: 'D-73 체류', - deadlineDays: 73, - note: '진행 업무 없음', - currentTasks: [], - timeline: [{ date: '06.02', label: '체류 상태 확인 완료', highlighted: false }], - }, - { - id: 'W-060', - name: '수반Y', - nationality: '네팔', - visaType: 'E-9', - employeeId: 'F-060', - phone: '010-****-4412', - deadlineLabel: '정상', - deadlineDays: null, - note: '진행 업무 없음', - currentTasks: [], - timeline: [{ date: '05.28', label: '입사 서류 확인 완료', highlighted: false }], - }, - { - id: 'W-061', - name: '남카Z', - nationality: '태국', - visaType: 'E-9', - employeeId: 'F-061', - phone: '010-****-7756', - deadlineLabel: 'D-30 체류', - deadlineDays: 30, - note: '진행 업무 없음', - currentTasks: [], - timeline: [{ date: '05.25', label: '체류 상태 확인 완료', highlighted: false }], - }, - { - id: 'W-062', - name: '흥타이AA', - nationality: '베트남', - visaType: 'E-9', - employeeId: 'F-062', - phone: '010-****-2245', - deadlineLabel: 'D-8 서류', - deadlineDays: 8, - note: '서류 누락 1건', - currentTasks: [{ caseId: 'WI-062', title: '외국인등록증 사본 제출 요청', detail: '오늘 · 근로자 응답 대기', linkTone: 'warning' }], - timeline: [{ date: '07.19', label: '서류 미제출 안내 발송', highlighted: false }], - }, - { - id: 'W-063', - name: '술리스탄BB', - nationality: '인도네시아', - visaType: 'E-9', - employeeId: 'F-063', - phone: '010-****-6634', - deadlineLabel: 'D-55 체류', - deadlineDays: 55, - note: '진행 업무 없음', - currentTasks: [], - timeline: [{ date: '05.20', label: '체류 상태 확인 완료', highlighted: false }], - }, -] - -export const TOTAL_WORKER_COUNT = 28 diff --git a/src/utils/documentLabels.ts b/src/utils/documentLabels.ts new file mode 100644 index 0000000..23c0796 --- /dev/null +++ b/src/utils/documentLabels.ts @@ -0,0 +1,21 @@ +import type { StatusTone } from '../components/ui/StatusLabel/StatusLabel' +import type { DocumentType, SubmissionStatus } from '../api/documents' + +export const DOCUMENT_TYPE_LABEL: Record = { + PASSPORT_COPY: '여권 사본', + ARC: '외국인등록증', + CONTRACT: '근로계약서', + PERMIT: '고용허가서', +} + +export const SUBMISSION_STATUS_LABEL: Record = { + MISSING: '미제출', + SUBMITTED: '확인 대기', + VERIFIED: '확인 완료', +} + +export const SUBMISSION_STATUS_TONE: Record = { + MISSING: 'critical', + SUBMITTED: 'warning', + VERIFIED: 'success', +}