Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/api/documents.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
44 changes: 44 additions & 0 deletions src/api/documents.ts
Original file line number Diff line number Diff line change
@@ -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<DocumentPageResponse> {
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<DocumentPageResponse>(`/documents?${query.toString()}`)
}
4 changes: 4 additions & 0 deletions src/pages/DocumentDetailPage/DocumentDetailPage.module.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
.stateWrap {
margin-top: 24px;
}

.topBar {
display: flex;
align-items: center;
Expand Down
93 changes: 70 additions & 23 deletions src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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>): 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(
Expand All @@ -12,43 +45,50 @@ function renderPage(documentId: string) {
<Route path="/documents/:documentId" element={<DocumentDetailPage />} />
<Route path="/documents" element={<p>서류 목록</p>} />
<Route path="/workers/:workerId" element={<p>근로자 상세</p>} />
<Route path="/tasks/:caseId" element={<p>업무 상세</p>} />
</Routes>
</MemoryRouter>,
)
}

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()
Expand All @@ -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()
})
})
106 changes: 64 additions & 42 deletions src/pages/DocumentDetailPage/DocumentDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,69 @@
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() {
const { documentId } = useParams()
const navigate = useNavigate()
const showToast = useToastStore((state) => state.showToast)

const document = DOCUMENTS.find((item) => item.id === documentId) ?? DOCUMENTS[0]
const [status, setStatus] = useState<DocumentStatus>(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<SubmissionStatus | null>(null)

if (fetchStatus === 'loading') {
return (
<div className={styles.stateWrap}>
<EmptyState kind="loading" title="서류 정보를 불러오는 중입니다" body="잠시만 기다려 주세요." />
</div>
)
}

if (fetchStatus === 'error') {
return (
<div className={styles.stateWrap}>
<EmptyState
kind="error"
title="서류 정보를 불러오지 못했습니다"
body={error ? getErrorMessage(error) : '네트워크 상태를 확인한 뒤 다시 시도해 주세요.'}
actionLabel="다시 시도"
onAction={refetch}
/>
</div>
)
}

if (!document) {
return (
<div className={styles.stateWrap}>
<EmptyState kind="empty" title="서류를 찾을 수 없습니다" body="서류 목록에서 다시 확인해 주세요." />
</div>
)
}

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('서류를 반려했습니다. 근로자에게 재제출을 요청하세요.')
}

Expand All @@ -41,53 +76,40 @@ export function DocumentDetailPage() {
</div>

<div className={styles.headerRow}>
<h1 className={styles.title}>{document.docType}</h1>
<StatusLabel tone={DOCUMENT_STATUS_TONE[status]}>{DOCUMENT_STATUS_LABEL[status]}</StatusLabel>
<h1 className={styles.title}>{DOCUMENT_TYPE_LABEL[document.document_type]}</h1>
<StatusLabel tone={SUBMISSION_STATUS_TONE[status]}>{SUBMISSION_STATUS_LABEL[status]}</StatusLabel>
</div>
<p className={styles.meta}>
{document.workerName} · 제출일 {document.submittedAt}
{document.display_name ?? '알 수 없음'} · 만료일 {document.expiry_date ?? '없음'}
</p>

<div className={styles.sectionCard}>
<h2 className={styles.cardTitle}>첨부 미리보기</h2>
{/* TODO(backend): GET /api/documents/:id/file -> 실제 첨부 파일 미리보기로 대체 */}
{/* TODO(backend): file_id로 실제 파일을 내려받는 API가 아직 없음 */}
<div className={styles.previewBox}>
<p className={styles.previewFileName}>{document.docType}.pdf</p>
<p className={styles.previewFileName}>{DOCUMENT_TYPE_LABEL[document.document_type]}</p>
<p className={styles.previewNote}>미리보기는 백엔드 연동 후 제공됩니다.</p>
</div>
</div>

<div className={styles.sectionCard}>
<h2 className={styles.cardTitle}>관련 근로자·업무</h2>
{relatedWorker ? (
<div className={styles.relatedLinks}>
<button
type="button"
className={styles.relatedLink}
onClick={() => navigate(`/workers/${relatedWorker.id}`)}
>
{relatedWorker.name} 근로자 정보 →
</button>
{relatedCaseId && (
<button
type="button"
className={styles.relatedLink}
onClick={() => navigate(`/tasks/${relatedCaseId}`)}
>
연결된 업무 열기 →
</button>
)}
</div>
) : (
<EmptyState kind="empty" title="연결된 근로자를 찾을 수 없습니다" body="근로자 목록에서 이름을 확인해 주세요." />
)}
<h2 className={styles.cardTitle}>관련 근로자</h2>
<div className={styles.relatedLinks}>
<button
type="button"
className={styles.relatedLink}
onClick={() => navigate(`/workers/${document.worker_id}`)}
>
{document.display_name ?? '근로자'} 정보 →
</button>
</div>
</div>

<div className={styles.actionDock}>
<Button variant="secondary" onClick={handleReject} disabled={status === 'missing'}>
<Button variant="secondary" onClick={handleReject} disabled={status === 'MISSING'}>
반려
</Button>
<Button onClick={handleApprove} disabled={status === 'done'}>
<Button onClick={handleApprove} disabled={status === 'VERIFIED'}>
확인 완료 처리
</Button>
</div>
Expand Down
Loading