Skip to content
Draft
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
60 changes: 59 additions & 1 deletion src/api/documents.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchDocuments, patchWorkerDocument, registerWorkerDocument } from './documents'
import {
fetchDocumentRequestDraft,
fetchDocuments,
patchWorkerDocument,
registerWorkerDocument,
upsertDocumentRequestDraft,
} from './documents'

function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } })
Expand Down Expand Up @@ -87,3 +93,55 @@ describe('patchWorkerDocument', () => {
expect(init?.method).toBe('PATCH')
})
})

describe('document request draft', () => {
it('GETs the saved content and version for recovery', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
jsonResponse({
draft_id: 'draft-1',
language: 'vi',
document_types: ['PASSPORT_COPY', 'CONTRACT'],
message: 'Vui lòng nộp hồ sơ.',
version: 3,
review_status: 'DRAFT',
updated_at: '2026-08-08T00:00:00Z',
}),
)

const draft = await fetchDocumentRequestDraft('T/1')

expect(draft.version).toBe(3)
const [url, init] = vi.mocked(fetch).mock.calls[0]
expect(url).toContain('/tasks/T%2F1/document-request-draft')
expect(init?.method).toBeUndefined()
})

it('PUTs the restored expected version with the edited message', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
jsonResponse({
draft_id: 'draft-1',
language: 'ko',
document_types: ['ARC'],
message: '외국인등록증을 제출해 주세요.',
version: 2,
review_status: 'DRAFT',
updated_at: '2026-08-08T00:00:00Z',
}),
)

await upsertDocumentRequestDraft('T/1', {
language: 'ko',
document_types: ['ARC'],
message: '외국인등록증을 제출해 주세요.',
expected_version: 1,
})

const [url, init] = vi.mocked(fetch).mock.calls[0]
expect(url).toContain('/tasks/T%2F1/document-request-draft')
expect(init?.method).toBe('PUT')
expect(JSON.parse(String(init?.body))).toMatchObject({
message: '외국인등록증을 제출해 주세요.',
expected_version: 1,
})
})
})
14 changes: 12 additions & 2 deletions src/api/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,22 @@ export interface DocumentRequestUpsertBody {

export interface DocumentRequestDraftResponse {
draft_id: string
language: string
document_types: DocumentType[]
message: string | null
version: number
review_status: string
updated_at: string
}

export function fetchDocumentRequestDraft(taskId: string): Promise<DocumentRequestDraftResponse> {
return apiFetch<DocumentRequestDraftResponse>(
`/tasks/${encodeURIComponent(taskId)}/document-request-draft`,
)
}

// 초안 저장만 하고 실제 발송·Worker Link 생성은 하지 않는다 (#176 스코프 아님).
// 최초 생성 시 expected_version은 관례상 0을 보낸다.
// 초안 저장만 하고 실제 발송·Worker Link 생성은 하지 않는다. 최초 생성 시
// expected_version은 0, 이후에는 fetchDocumentRequestDraft가 반환한 최신 version을 보낸다.
export function upsertDocumentRequestDraft(
taskId: string,
body: DocumentRequestUpsertBody,
Expand Down
3 changes: 3 additions & 0 deletions src/api/workerLinks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { apiFetch } from './client'
import type { DocumentType } from './documents'

export type WorkerResponseType =
| 'ACKNOWLEDGED'
Expand All @@ -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[]
}

Expand Down
137 changes: 137 additions & 0 deletions src/pages/CaseDetailPage/CaseDetailPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,143 @@
color: var(--text-secondary);
}

.documentRequestStatus {
margin: 16px 0 0;
color: var(--text-secondary);
font-size: 13px;
}

.documentRequestError {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 16px;
padding: var(--fowoco-spacing-12) var(--fowoco-spacing-16);
background: var(--fowoco-red-50);
border-radius: var(--fowoco-radius-8);
}

.documentRequestError p {
margin: 0;
color: var(--status-critical);
font-size: 13px;
}

.documentRequestError button {
flex-shrink: 0;
padding: 0;
background: none;
border: none;
color: var(--status-critical);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}

.documentRequestCard {
margin-top: 16px;
padding: var(--fowoco-spacing-20) var(--fowoco-spacing-24);
background: var(--surface-default);
border: 1px solid var(--border-default);
border-radius: var(--fowoco-radius-8);
}

.documentRequestHeader,
.documentRequestFooter {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}

.documentRequestEyebrow {
margin: 0 0 4px;
color: var(--brand-primary);
font-size: 12px;
font-weight: 700;
}

.documentRequestTitle {
margin: 0;
color: var(--text-primary);
font-size: 16px;
}

.documentRequestVersion {
color: var(--text-secondary);
font-size: 12px;
}

.documentRequestTypes {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}

.documentRequestTypes span {
padding: 6px 10px;
background: var(--surface-subtle);
border-radius: 999px;
color: var(--text-secondary);
font-size: 12px;
}

.documentRequestLabel {
display: block;
margin: 18px 0 8px;
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
}

.documentRequestSelect,
.documentRequestTextarea {
width: 100%;
padding: 10px 12px;
background: var(--surface-default);
border: 1px solid var(--border-default);
border-radius: var(--fowoco-radius-8);
color: var(--text-primary);
font: inherit;
}

.documentRequestTextarea {
min-height: 104px;
line-height: 1.6;
resize: vertical;
}

.documentRequestSelect:focus-visible,
.documentRequestTextarea:focus-visible {
outline: 2px solid var(--brand-primary);
outline-offset: 2px;
}

.documentRequestFooter {
margin-top: 12px;
color: var(--text-secondary);
font-size: 12px;
}

.documentRequestAction {
min-height: 40px;
padding: 0 16px;
background: var(--brand-primary);
border: none;
border-radius: var(--fowoco-radius-8);
color: var(--fowoco-white);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}

.documentRequestAction:disabled {
cursor: wait;
opacity: 0.6;
}

.commList {
display: flex;
flex-direction: column;
Expand Down
95 changes: 90 additions & 5 deletions src/pages/CaseDetailPage/CaseDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import type {
DocumentItemResponse,
DocumentPageResponse,
DocumentReadinessResponse,
DocumentRequestDraftResponse,
} from '../../api/documents'
import type { TaskDetailResponse } from '../../api/tasks'
import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport'
import { useAuthStore } from '../../store/authStore'
import { useToastStore } from '../../store/toastStore'
import { CaseDetailPage } from './CaseDetailPage'
import { CASE_COMMUNICATION, CASE_TABS, CONTEXT_DRAWER } from './caseDetailData'
Expand Down Expand Up @@ -124,16 +126,44 @@ function mockTaskAndActivities(
activities: AuditEventResponse[] = [],
readinessOverrides: Partial<DocumentReadinessResponse> = {},
documents: DocumentItemResponse[] = [],
savedDocumentRequestDraft: DocumentRequestDraftResponse | null = null,
) {
vi.mocked(fetch).mockImplementation((input) => {
let currentDocumentRequestDraft = savedDocumentRequestDraft
vi.mocked(fetch).mockImplementation((input, init) => {
const url = String(input)
if (url.includes('/activities')) return Promise.resolve(jsonResponse(activities))
if (url.includes('/document-readiness'))
return Promise.resolve(jsonResponse(readinessResponse(readinessOverrides)))
if (url.includes('/document-request-draft')) {
return Promise.resolve(
jsonResponse({ draft_id: 'draft-1', version: 1, review_status: 'PENDING' }),
)
if (init?.method !== 'PUT') {
return Promise.resolve(
currentDocumentRequestDraft
? jsonResponse(currentDocumentRequestDraft)
: errorResponse(404, 'DOCUMENT_REQUEST_DRAFT_NOT_FOUND', '초안 없음'),
)
}
const body = JSON.parse(String(init.body)) as {
language: string
document_types: DocumentRequestDraftResponse['document_types']
message: string
expected_version: number
}
if (
currentDocumentRequestDraft &&
body.expected_version !== currentDocumentRequestDraft.version
) {
return Promise.resolve(errorResponse(409, 'VERSION_CONFLICT', '초안 버전 충돌'))
}
currentDocumentRequestDraft = {
draft_id: currentDocumentRequestDraft?.draft_id ?? 'draft-1',
language: body.language,
document_types: body.document_types,
message: body.message,
version: currentDocumentRequestDraft ? currentDocumentRequestDraft.version + 1 : 0,
review_status: 'DRAFT',
updated_at: '2026-08-08T00:00:00Z',
}
return Promise.resolve(jsonResponse(currentDocumentRequestDraft))
}
if (url.includes('/documents?'))
return Promise.resolve(jsonResponse(documentsResponse(documents)))
Expand Down Expand Up @@ -197,6 +227,7 @@ function mockTaskError(status: number, code: string, message: string) {
}

beforeEach(() => {
useAuthStore.setState({ user: null, status: 'ready' })
useToastStore.setState({ toasts: [] })
vi.stubGlobal('fetch', vi.fn())
})
Expand Down Expand Up @@ -338,9 +369,63 @@ describe('CaseDetailPage', () => {
expect(await screen.findByText('누락 1건 · 만료 0건')).toBeInTheDocument()

await user.click(screen.getByRole('tab', { name: CASE_TABS[2] }))
await user.click(await screen.findByRole('button', { name: '요청 초안 저장' }))
await user.click(await screen.findByRole('button', { name: '요청 초안 저장' }))

expect(await screen.findByText('서류 요청 초안을 저장했습니다.')).toBeInTheDocument()
const saveCall = vi.mocked(fetch).mock.calls.find(
([url, init]) =>
String(url).includes('/document-request-draft') && init?.method === 'PUT',
)
expect(JSON.parse(String(saveCall?.[1]?.body))).toMatchObject({
document_types: ['ARC'],
message: '다음 서류를 제출해 주세요: 외국인등록증.',
expected_version: 0,
})
})

it('restores and updates a saved document request draft', async () => {
const user = userEvent.setup()
mockTaskAndActivities(
{},
[],
{ missing: ['ARC'], completion_blocked: true },
[],
{
draft_id: 'draft-1',
language: 'vi',
document_types: ['PASSPORT_COPY', 'CONTRACT'],
message: 'Vui lòng nộp hộ chiếu và hợp đồng lao động.',
version: 3,
review_status: 'DRAFT',
updated_at: '2026-08-08T00:00:00Z',
},
)
renderPage()
await screen.findByText('응웬반A 체류연장 준비')
await user.click(screen.getByRole('tab', { name: CASE_TABS[2] }))

expect(await screen.findByDisplayValue('Vui lòng nộp hộ chiếu và hợp đồng lao động.')).toBeInTheDocument()
expect(screen.getByLabelText('안내 언어')).toHaveValue('vi')
expect(screen.getByText('저장본 v3')).toBeInTheDocument()

const message = screen.getByLabelText('근로자 안내문')
await user.clear(message)
await user.type(message, '수정된 안내문')
await user.click(screen.getByRole('button', { name: '요청 초안 저장' }))

const updateCall = await waitFor(() => {
const call = vi.mocked(fetch).mock.calls.find(
([url, init]) =>
String(url).includes('/document-request-draft') && init?.method === 'PUT',
)
expect(call).toBeDefined()
return call!
})
expect(JSON.parse(String(updateCall[1]?.body))).toMatchObject({
message: '수정된 안내문',
expected_version: 3,
})
expect(await screen.findByText('저장본 v4')).toBeInTheDocument()
})

it('switches to the communication tab and shows message content', async () => {
Expand Down
Loading
Loading