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
92 changes: 92 additions & 0 deletions src/pages/CaseDetailPage/CaseDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,98 @@ describe('CaseDetailPage', () => {
).toBe(false)
})

it('prefills the AI bilingual guide draft for HR review and saves it', async () => {
const user = userEvent.setup()
mockTaskAndActivities(
{
business_data: {
renewal_execution: {
guide_review_required: true,
guide_failure_code: 'LANGUAGE_ASSISTANT_REVIEW_REQUIRED',
guide_review_draft: {
target_language: 'mn',
standard_korean_text: '다음 요청 내용을 확인해 주세요.',
easy_korean_text: '여권 사본을 준비해 주세요.',
translated_text: 'Паспортын хуулбараа бэлдэнэ үү.',
warning_codes: ['RETRIEVAL_UNAVAILABLE'],
},
},
},
},
[],
{ missing: ['PASSPORT_COPY'], completion_blocked: true },
)
renderPage()
await screen.findByText('응웬반A 체류연장 준비')

await user.click(screen.getByRole('tab', { name: CASE_TABS[2] }))

expect(screen.getByLabelText('안내 언어')).toHaveValue('mn')
expect(screen.getByLabelText('근로자 안내문')).toHaveValue(
'[쉬운 한국어]\n여권 사본을 준비해 주세요.\n\n[대상 언어 안내]\nПаспортын хуулбараа бэлдэнэ үү.',
)

await user.click(screen.getByRole('button', { name: '요청 초안 저장' }))

const saveCall = 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(saveCall[1]?.body))).toMatchObject({
language: 'mn',
message:
'[쉬운 한국어]\n여권 사본을 준비해 주세요.\n\n[대상 언어 안내]\nПаспортын хуулбараа бэлдэнэ үү.',
})
})

it('keeps the saved HR draft instead of replacing it with an AI review draft', async () => {
const user = userEvent.setup()
mockTaskAndActivities(
{
business_data: {
renewal_execution: {
guide_review_required: true,
guide_failure_code: 'LANGUAGE_ASSISTANT_REVIEW_REQUIRED',
guide_review_draft: {
target_language: 'mn',
easy_korean_text: 'AI가 만든 안내',
translated_text: 'AI орчуулга',
},
},
},
},
[],
{ missing: ['PASSPORT_COPY'], completion_blocked: true },
[],
[],
null,
null,
{
draft_id: 'draft-1',
language: 'vi',
document_types: ['PASSPORT_COPY'],
message: 'HR이 검토하고 저장한 안내문',
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(screen.getByLabelText('안내 언어')).toHaveValue('vi')
expect(screen.getByLabelText('근로자 안내문')).toHaveValue('HR이 검토하고 저장한 안내문')
expect(screen.queryByDisplayValue(/AI가 만든 안내/)).not.toBeInTheDocument()
})

it('shows real unread worker responses and marks them as reviewed', async () => {
const user = userEvent.setup()
mockTaskAndActivities(
Expand Down
57 changes: 43 additions & 14 deletions src/pages/CaseDetailPage/CaseDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { TASK_SOURCE_LABEL, TASK_STATUS_LABEL, TASK_STATUS_TONE } from '../../ut
import { getDocumentViewModel } from '../../view-models/documentViewModel'
import { getOperationalDateViewModel } from '../../view-models/dateViewModel'
import {
buildWorkerGuideReviewMessage,
getWorkerGuideReviewPresentation,
getWorkerGuideReviewState,
} from '../../view-models/workerGuideReviewViewModel'
Expand Down Expand Up @@ -166,6 +167,7 @@ interface DocumentRequestDraftForm {
taskId: string
sourceVersion: number | null
guideReviewRequired: boolean
dirty: boolean
language: string
documentTypes: DocumentType[]
message: string
Expand Down Expand Up @@ -345,16 +347,27 @@ export function CaseDetailPage() {
])
const savedVersion = documentRequestDraft?.version ?? null
const preferredLanguage = worker?.preferred_language?.trim() || 'ko'
const guideReviewRequired = task
? getWorkerGuideReviewState(task.business_data).required
: false
const guideReviewState = task
? getWorkerGuideReviewState(task.business_data)
: { required: false, failureCode: null, draft: null }
const guideReviewRequired = guideReviewState.required
const guideReviewMessage = guideReviewRequired
? buildWorkerGuideReviewMessage(guideReviewState.draft)
: ''
const guideReviewLanguage = guideReviewRequired
? (guideReviewState.draft?.targetLanguage ?? preferredLanguage)
: preferredLanguage

setDocumentRequestDraftForm((current) => {
if (documentRequestDraft) {
if (current?.taskId === taskId && current.sourceVersion === savedVersion && current.dirty) {
return current
}
return {
taskId,
sourceVersion: savedVersion,
guideReviewRequired,
dirty: false,
language: documentRequestDraft.language,
documentTypes: documentRequestDraft.document_types,
message: documentRequestDraft.message ?? '',
Expand All @@ -369,16 +382,25 @@ export function CaseDetailPage() {
...current.documentTypes,
...inferredDocumentTypes,
])
if (current.dirty) {
return {
...current,
documentTypes,
}
}
const canUseDefaultMessage = !guideReviewRequired && current.language === 'ko'
const message =
current.message &&
current.message !== defaultDocumentRequestMessage(current.documentTypes)
? current.message
: canUseDefaultMessage
? defaultDocumentRequestMessage(documentTypes)
: ''
: guideReviewMessage
? guideReviewMessage
: canUseDefaultMessage
? defaultDocumentRequestMessage(documentTypes)
: ''
return {
...current,
language: guideReviewState.draft?.targetLanguage ?? current.language,
documentTypes,
message,
}
Expand All @@ -387,12 +409,14 @@ export function CaseDetailPage() {
taskId,
sourceVersion: null,
guideReviewRequired,
language: preferredLanguage,
dirty: false,
language: guideReviewLanguage,
documentTypes: inferredDocumentTypes,
message:
!guideReviewRequired && preferredLanguage === 'ko'
guideReviewMessage ||
(!guideReviewRequired && guideReviewLanguage === 'ko'
? defaultDocumentRequestMessage(inferredDocumentTypes)
: '',
: ''),
}
})
setDocumentRequestDraftVersion((current) => {
Expand Down Expand Up @@ -718,7 +742,7 @@ export function CaseDetailPage() {
setDocumentRequestDraftVersion({ taskId: task.task_id, version: savedDraft.version })
setDocumentRequestDraftForm((current) =>
current?.taskId === task.task_id
? { ...current, sourceVersion: savedDraft.version, message }
? { ...current, sourceVersion: savedDraft.version, dirty: false, message }
: current,
)
await refetchDocumentRequestDraft()
Expand Down Expand Up @@ -942,6 +966,8 @@ export function CaseDetailPage() {
const guideReview = guideReviewState.required
? getWorkerGuideReviewPresentation(guideReviewState.failureCode)
: null
const guideReviewDraftAvailable =
guideReviewState.required && buildWorkerGuideReviewMessage(guideReviewState.draft) !== ''
const approvalBadge = getApprovalBadge(task.status)
const requiredChecklist = task.checklist_items.filter((item) => item.required)
const completedRequiredChecklist = requiredChecklist.filter((item) => item.completed).length
Expand Down Expand Up @@ -1492,8 +1518,11 @@ export function CaseDetailPage() {
<div className={styles.documentRequestReview} role="alert">
<strong>{guideReview.title}</strong>
<p>
{guideReview.description} 대상 언어를 확인하고 안내문을 직접 작성해 저장해
주세요. 저장 전에는 근로자에게 전달되지 않습니다.
{guideReview.description}{' '}
{guideReviewDraftAvailable
? '자동 생성된 초안의 대상 언어와 내용을 확인·수정해 저장해 주세요.'
: '대상 언어를 확인하고 안내문을 직접 작성해 저장해 주세요.'}{' '}
저장 전에는 근로자에게 전달되지 않습니다.
</p>
</div>
)}
Expand All @@ -1508,7 +1537,7 @@ export function CaseDetailPage() {
onChange={(event) =>
setDocumentRequestDraftForm((current) =>
current?.taskId === task.task_id
? { ...current, language: event.target.value }
? { ...current, language: event.target.value, dirty: true }
: current,
)
}
Expand Down Expand Up @@ -1539,7 +1568,7 @@ export function CaseDetailPage() {
onChange={(event) =>
setDocumentRequestDraftForm((current) =>
current?.taskId === task.task_id
? { ...current, message: event.target.value }
? { ...current, message: event.target.value, dirty: true }
: current,
)
}
Expand Down
50 changes: 50 additions & 0 deletions src/view-models/workerGuideReviewViewModel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import {
buildWorkerGuideReviewMessage,
getWorkerGuideReviewState,
} from './workerGuideReviewViewModel'

describe('workerGuideReviewViewModel', () => {
it('parses and presents the bilingual AI review draft', () => {
const state = getWorkerGuideReviewState({
renewal_execution: {
guide_review_required: true,
guide_failure_code: 'LANGUAGE_ASSISTANT_REVIEW_REQUIRED',
guide_review_draft: {
target_language: 'mn',
standard_korean_text: '서류를 준비해 주세요.',
easy_korean_text: '여권 사본을 준비해 주세요.',
translated_text: 'Паспортын хуулбараа бэлдэнэ үү.',
warning_codes: ['RETRIEVAL_UNAVAILABLE'],
},
},
})

expect(state).toMatchObject({
required: true,
failureCode: 'LANGUAGE_ASSISTANT_REVIEW_REQUIRED',
draft: {
targetLanguage: 'mn',
warningCodes: ['RETRIEVAL_UNAVAILABLE'],
},
})
expect(buildWorkerGuideReviewMessage(state.draft)).toBe(
'[쉬운 한국어]\n여권 사본을 준비해 주세요.\n\n[대상 언어 안내]\nПаспортын хуулбараа бэлдэнэ үү.',
)
})

it('does not duplicate identical Korean and translated text', () => {
const state = getWorkerGuideReviewState({
renewal_execution: {
guide_review_required: true,
guide_review_draft: {
target_language: 'ko',
easy_korean_text: '여권 사본을 준비해 주세요.',
translated_text: '여권 사본을 준비해 주세요.',
},
},
})

expect(buildWorkerGuideReviewMessage(state.draft)).toBe('여권 사본을 준비해 주세요.')
})
})
53 changes: 52 additions & 1 deletion src/view-models/workerGuideReviewViewModel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
export interface WorkerGuideReviewState {
required: boolean
failureCode: string | null
draft: WorkerGuideReviewDraft | null
}

export interface WorkerGuideReviewDraft {
targetLanguage: string | null
standardKoreanText: string | null
easyKoreanText: string | null
translatedText: string | null
warningCodes: string[]
}

export interface WorkerGuideReviewPresentation {
Expand All @@ -15,22 +24,64 @@ const FAILURE_DESCRIPTIONS: Record<string, string> = {
WORKER_GUIDE_UNAVAILABLE: '안전하게 사용할 수 있는 근로자 안내문을 만들지 못했습니다.',
}

function optionalString(value: unknown): string | null {
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
}

function parseGuideReviewDraft(value: unknown): WorkerGuideReviewDraft | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null

const draft = value as Record<string, unknown>
const parsed = {
targetLanguage: optionalString(draft.target_language),
standardKoreanText: optionalString(draft.standard_korean_text),
easyKoreanText: optionalString(draft.easy_korean_text),
translatedText: optionalString(draft.translated_text),
warningCodes: Array.isArray(draft.warning_codes)
? draft.warning_codes.filter((code): code is string => typeof code === 'string')
: [],
}

return parsed.targetLanguage ||
parsed.standardKoreanText ||
parsed.easyKoreanText ||
parsed.translatedText ||
parsed.warningCodes.length > 0
? parsed
: null
}

export function getWorkerGuideReviewState(
businessData: Record<string, unknown>,
): WorkerGuideReviewState {
const execution = businessData.renewal_execution
if (!execution || typeof execution !== 'object' || Array.isArray(execution)) {
return { required: false, failureCode: null }
return { required: false, failureCode: null, draft: null }
}

const metadata = execution as Record<string, unknown>
return {
required: metadata.guide_review_required === true,
failureCode:
typeof metadata.guide_failure_code === 'string' ? metadata.guide_failure_code : null,
draft: parseGuideReviewDraft(metadata.guide_review_draft),
}
}

export function buildWorkerGuideReviewMessage(draft: WorkerGuideReviewDraft | null): string {
if (!draft) return ''

const koreanText = draft.easyKoreanText ?? draft.standardKoreanText
const translatedText = draft.translatedText

if (!koreanText) return translatedText ?? ''
if (!translatedText || translatedText === koreanText || draft.targetLanguage === 'ko') {
return koreanText
}

return `[쉬운 한국어]\n${koreanText}\n\n[대상 언어 안내]\n${translatedText}`
}

export function getWorkerGuideReviewPresentation(
failureCode: string | null,
): WorkerGuideReviewPresentation {
Expand Down