diff --git a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx index 3307c9f..d4425ac 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx @@ -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( diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx index 3faf3a3..b144802 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx @@ -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' @@ -166,6 +167,7 @@ interface DocumentRequestDraftForm { taskId: string sourceVersion: number | null guideReviewRequired: boolean + dirty: boolean language: string documentTypes: DocumentType[] message: string @@ -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 ?? '', @@ -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, } @@ -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) => { @@ -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() @@ -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 @@ -1492,8 +1518,11 @@ export function CaseDetailPage() {
{guideReview.title}

- {guideReview.description} 대상 언어를 확인하고 안내문을 직접 작성해 저장해 - 주세요. 저장 전에는 근로자에게 전달되지 않습니다. + {guideReview.description}{' '} + {guideReviewDraftAvailable + ? '자동 생성된 초안의 대상 언어와 내용을 확인·수정해 저장해 주세요.' + : '대상 언어를 확인하고 안내문을 직접 작성해 저장해 주세요.'}{' '} + 저장 전에는 근로자에게 전달되지 않습니다.

)} @@ -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, ) } @@ -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, ) } diff --git a/src/view-models/workerGuideReviewViewModel.test.ts b/src/view-models/workerGuideReviewViewModel.test.ts new file mode 100644 index 0000000..6a08281 --- /dev/null +++ b/src/view-models/workerGuideReviewViewModel.test.ts @@ -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('여권 사본을 준비해 주세요.') + }) +}) diff --git a/src/view-models/workerGuideReviewViewModel.ts b/src/view-models/workerGuideReviewViewModel.ts index 1ced3b9..64df924 100644 --- a/src/view-models/workerGuideReviewViewModel.ts +++ b/src/view-models/workerGuideReviewViewModel.ts @@ -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 { @@ -15,12 +24,39 @@ const FAILURE_DESCRIPTIONS: Record = { 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 + 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, ): 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 @@ -28,9 +64,24 @@ export function getWorkerGuideReviewState( 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 {