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
35 changes: 35 additions & 0 deletions src/api/workerLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,21 @@ export interface WorkerResponseSubmitResponse {

export type WorkerConversationStatus = 'WAITING_WORKER' | 'NEEDS_FOLLOWUP' | 'REOPENED'

export interface WorkerResponseUploadItem {
file_id: string
file_name: string
mime_type: string
size: number
document_type: DocumentType | null
adopted: boolean
}

export interface WorkerResponseItemResponse {
response_id: string
response_type: WorkerResponseType
message: string | null
upload_ids: string[]
uploads: WorkerResponseUploadItem[]
conversation_status: WorkerConversationStatus
unread: boolean
received_at: string
Expand All @@ -77,6 +87,17 @@ export interface WorkerResponsePageResponse {
total_pages: number
}

export interface WorkerResponseDocumentAdoptionResponse {
response_id: string
adopted_documents: Array<{
worker_document_id: string
file_id: string
document_type: DocumentType
}>
task_status: string
task_version: number
}

export function issueWorkerLink(
taskId: string,
body: WorkerLinkIssueBody,
Expand Down Expand Up @@ -180,6 +201,20 @@ export function markTaskWorkerResponsesRead(taskId: string): Promise<void> {
})
}

export function adoptWorkerResponseDocuments(
taskId: string,
responseId: string,
expectedTaskVersion: number,
): Promise<WorkerResponseDocumentAdoptionResponse> {
return apiFetch<WorkerResponseDocumentAdoptionResponse>(
`/tasks/${encodeURIComponent(taskId)}/worker-responses/${encodeURIComponent(responseId)}/documents/adopt`,
{
method: 'POST',
body: JSON.stringify({ expected_task_version: expectedTaskVersion }),
},
)
}

export function resolveWorkerPortalUrl(workerUrlOrToken: string, origin: string): string {
if (/^https?:\/\//i.test(workerUrlOrToken)) return workerUrlOrToken
if (workerUrlOrToken.startsWith('/')) return new URL(workerUrlOrToken, origin).toString()
Expand Down
81 changes: 81 additions & 0 deletions src/pages/CaseDetailPage/CaseDetailPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,77 @@
color: var(--text-secondary);
}

.responseFiles {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}

.responseFileRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 12px;
background: var(--surface-subtle);
border: 1px solid var(--border-default);
border-radius: var(--fowoco-radius-8);
}

.responseFileCopy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}

.responseFileName {
overflow: hidden;
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}

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

.responseFileAction,
.adoptDocumentsAction {
flex-shrink: 0;
border-radius: var(--fowoco-radius-6);
cursor: pointer;
font-size: 12px;
font-weight: 600;
}

.responseFileAction {
min-height: 34px;
padding: 0 12px;
background: var(--surface-default);
border: 1px solid var(--border-default);
color: var(--text-primary);
}

.adoptDocumentsAction {
align-self: flex-end;
min-height: 38px;
padding: 0 16px;
background: var(--brand-primary);
border: 0;
color: var(--fowoco-white);
}

.responseFileAction:disabled,
.adoptDocumentsAction:disabled {
cursor: not-allowed;
opacity: 0.5;
}

.timeline {
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -803,6 +874,16 @@
align-items: center;
flex-direction: row;
}

.responseFileRow {
align-items: stretch;
flex-direction: column;
}

.responseFileAction,
.adoptDocumentsAction {
align-self: stretch;
}
}

.contextSection + .contextSection {
Expand Down
85 changes: 83 additions & 2 deletions src/pages/CaseDetailPage/CaseDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ function mockTaskAndActivities(
savedDocumentRequestDraft: DocumentRequestDraftResponse | null = null,
) {
let responsesReviewed = false
let currentWorkerResponses = workerResponses
let currentWorkerLinkDelivery = workerLinkDelivery
let currentDocumentRequestDraft = savedDocumentRequestDraft
vi.mocked(fetch).mockImplementation((input, init) => {
Expand All @@ -198,8 +199,27 @@ function mockTaskAndActivities(
responsesReviewed = true
return Promise.resolve(new Response(null, { status: 204 }))
}
if (url.includes('/worker-responses/') && url.endsWith('/documents/adopt')) {
currentWorkerResponses = currentWorkerResponses.map((response) => ({
...response,
uploads: response.uploads.map((upload) => ({ ...upload, adopted: true })),
}))
return Promise.resolve(
jsonResponse({
response_id: 'response-1',
adopted_documents:
currentWorkerResponses[0]?.uploads.map((upload) => ({
worker_document_id: `document-${upload.file_id}`,
file_id: upload.file_id,
document_type: upload.document_type,
})) ?? [],
task_status: 'APPROVED',
task_version: 2,
}),
)
}
if (url.includes('/worker-responses?')) {
const items = workerResponses.map((response) =>
const items = currentWorkerResponses.map((response) =>
responsesReviewed
? { ...response, unread: false, conversation_status: 'REOPENED' as const }
: response,
Expand Down Expand Up @@ -605,6 +625,16 @@ describe('CaseDetailPage', () => {
response_type: 'DOCUMENT_SUBMITTED',
message: '여권 사본을 제출했습니다.',
upload_ids: ['upload-1'],
uploads: [
{
file_id: 'upload-1',
file_name: 'passport.pdf',
mime_type: 'application/pdf',
size: 2048,
document_type: 'PASSPORT_COPY',
adopted: false,
},
],
conversation_status: 'NEEDS_FOLLOWUP',
unread: true,
received_at: '2026-07-20T01:00:00Z',
Expand All @@ -618,7 +648,8 @@ describe('CaseDetailPage', () => {

expect(screen.getByText('승인대기')).toBeInTheDocument()
expect(screen.getByText('여권 사본을 제출했습니다.')).toBeInTheDocument()
expect(screen.getByText('제출 파일 1개')).toBeInTheDocument()
expect(screen.getByText('passport.pdf')).toBeInTheDocument()
expect(screen.getByText(/여권 사본 · 2KB · 검토 필요/)).toBeInTheDocument()
expect(screen.queryByText('여권 사본 요청문 초안을 준비했습니다.')).not.toBeInTheDocument()

await user.click(screen.getByRole('button', { name: '응답 확인 완료' }))
Expand Down Expand Up @@ -689,6 +720,7 @@ describe('CaseDetailPage', () => {
response_type: 'QUESTION',
message: '어떤 서류가 필요한가요?',
upload_ids: [],
uploads: [],
conversation_status: 'NEEDS_FOLLOWUP',
unread: true,
received_at: '2026-07-20T01:00:00Z',
Expand All @@ -704,6 +736,55 @@ describe('CaseDetailPage', () => {
expect(screen.queryByRole('button', { name: '응답 확인 완료' })).not.toBeInTheDocument()
})

it('adopts a submitted file as an official worker document', async () => {
const user = userEvent.setup()
useAuthStore.setState({
user: { name: 'hr', workplace: 'FOWOCO', role: 'HR' },
status: 'ready',
})
mockTaskAndActivities(
{ status: 'WAITING_WORKER', version: 3 },
[],
{},
[],
[
{
response_id: 'response-1',
response_type: 'DOCUMENT_SUBMITTED',
message: '여권을 제출했습니다.',
upload_ids: ['upload-1'],
uploads: [
{
file_id: 'upload-1',
file_name: 'passport.pdf',
mime_type: 'application/pdf',
size: 2048,
document_type: 'PASSPORT_COPY',
adopted: false,
},
],
conversation_status: 'NEEDS_FOLLOWUP',
unread: true,
received_at: '2026-07-20T01:00:00Z',
},
],
)
renderPage()
await screen.findByText('응웬반A 체류연장 준비')

await user.click(screen.getByRole('tab', { name: CASE_TABS[3] }))
await user.click(screen.getByRole('button', { name: '확인 후 공식 서류로 등록' }))

expect(
await screen.findByText('제출 파일을 공식 근로자 서류로 등록했습니다.'),
).toBeInTheDocument()
await waitFor(() => expect(screen.getByText(/공식 서류 등록됨/)).toBeInTheDocument())
const adoptionCall = vi
.mocked(fetch)
.mock.calls.find(([url]) => String(url).endsWith('/documents/adopt'))
expect(JSON.parse(String(adoptionCall?.[1]?.body))).toEqual({ expected_task_version: 3 })
})

it('switches to the activity tab and shows real activity content', async () => {
const user = userEvent.setup()
mockTaskAndActivities({}, [activity()])
Expand Down
Loading