From 04af51d0d4bc74bb25a057090b1809e832a2a3e0 Mon Sep 17 00:00:00 2001 From: mini Date: Tue, 11 Aug 2026 13:16:01 +0900 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20Renewal=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=ED=9B=84=EC=86=8D=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20(server#131=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #311. server#131/ai#31이 병합되면 renewal-executions 트리거·결과 표시 UI 구현 예정. --- src/api/tasks.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/api/tasks.ts b/src/api/tasks.ts index 33de58e..c5c27e1 100644 --- a/src/api/tasks.ts +++ b/src/api/tasks.ts @@ -129,10 +129,7 @@ interface CreateTaskFields { } export type CreateTaskBody = CreateTaskFields & - ( - | { target_type?: 'WORKER'; worker_id: string } - | { target_type: 'COMPANY'; worker_id?: never } - ) + ({ target_type?: 'WORKER'; worker_id: string } | { target_type: 'COMPANY'; worker_id?: never }) export function createTask(body: CreateTaskBody): Promise { return apiFetch('/tasks', { method: 'POST', body: JSON.stringify(body) }) @@ -181,3 +178,7 @@ export function cancelTask(taskId: string, body: CancelTaskBody): Promise Date: Wed, 12 Aug 2026 09:35:39 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20Renewal=20Agent=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20client=20=EC=97=B0=EB=8F=99=20(server#131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/api/renewal.ts: runRenewalExecution (POST /tasks/{taskId}/renewal-run) - RenewalExecutionModal: 요청 입력 -> 실행 -> 결과(outcome/상태/생성문서/근로자 안내초안) 표시, missing_slots 중 source_hint=USER_INPUT인 필드만 HR이 답변해서 재실행 가능 (OCR 등 비HR 필드는 안내만) - CaseDetailPage 더보기 메뉴에 RECONTRACT/EMPLOYMENT_PERIOD_EXTENSION/ STAY_PERIOD_EXTENSION 업무에서만 'Renewal 실행' 노출 - 실행 후 task/documents/activities/readiness 재조회 closes #311 --- src/api/renewal.ts | 52 ++++ src/api/tasks.ts | 4 - .../CaseDetailPage/CaseDetailPage.test.tsx | 23 ++ src/pages/CaseDetailPage/CaseDetailPage.tsx | 41 ++- .../overlays/RenewalExecutionModal.test.tsx | 160 ++++++++++++ .../overlays/RenewalExecutionModal.tsx | 236 ++++++++++++++++++ 6 files changed, 511 insertions(+), 5 deletions(-) create mode 100644 src/api/renewal.ts create mode 100644 src/pages/CaseDetailPage/overlays/RenewalExecutionModal.test.tsx create mode 100644 src/pages/CaseDetailPage/overlays/RenewalExecutionModal.tsx diff --git a/src/api/renewal.ts b/src/api/renewal.ts new file mode 100644 index 0000000..1912610 --- /dev/null +++ b/src/api/renewal.ts @@ -0,0 +1,52 @@ +import { apiFetch } from './client' +import type { TaskStatus } from './tasks' + +export interface RenewalRequestedField { + key: string + source_hint: string +} + +export interface GeneratedDocumentResult { + template_id: string + format: string + status: string + stored_file_id: string | null + worker_document_id: string | null +} + +export interface RenewalExecutionResponse { + request_id: string + task_id: string + task_status: TaskStatus + task_version: number + intent: string + workflow_id: string + confidence: number + scenario: string + outcome: string + missing_slots: string[] + requested_fields: RenewalRequestedField[] + case_signals: string[] + generated_documents: GeneratedDocumentResult[] + worker_message_draft_id: string | null + worker_message_draft_version: number | null + human_review_required: boolean +} + +export interface RenewalExecutionBody { + instruction: string + expected_version: number + slot_answers?: Record +} + +// fowoco/server RenewalExecutionController 기준. slot_answers는 requested_fields 중 +// source_hint가 "USER_INPUT"인 key만 허용 — 그 외(OCR 등)를 보내면 422로 거부된다. +export function runRenewalExecution( + taskId: string, + body: RenewalExecutionBody, +): Promise { + return apiFetch(`/tasks/${encodeURIComponent(taskId)}/renewal-run`, { + method: 'POST', + body: JSON.stringify(body), + }) +} diff --git a/src/api/tasks.ts b/src/api/tasks.ts index c5c27e1..ed4981b 100644 --- a/src/api/tasks.ts +++ b/src/api/tasks.ts @@ -178,7 +178,3 @@ export function cancelTask(taskId: string, body: CancelTaskBody): Promise { expect(screen.getByRole('menu', { name: '업무 더보기 메뉴' })).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: '취소' })).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: '담당자 변경' })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'Renewal 실행' })).toBeInTheDocument() await user.click(moreButton) expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) + it('does not show Renewal 실행 for non-renewal task types', async () => { + const user = userEvent.setup() + mockTaskAndActivities({ task_type: 'DOCUMENT_REQUEST' }) + renderPage() + await screen.findByText('응웬반A 체류연장 준비') + + await user.click(screen.getByRole('button', { name: '더보기 ···' })) + expect(screen.queryByRole('menuitem', { name: 'Renewal 실행' })).not.toBeInTheDocument() + }) + + it('opens the Renewal execution modal from the more menu', async () => { + const user = userEvent.setup() + mockTaskAndActivities() + renderPage() + await screen.findByText('응웬반A 체류연장 준비') + + await user.click(screen.getByRole('button', { name: '더보기 ···' })) + await user.click(screen.getByRole('menuitem', { name: 'Renewal 실행' })) + + expect(screen.getByRole('dialog', { name: 'Renewal Agent 실행' })).toBeInTheDocument() + }) + it('shows a toast when the assignee change action is clicked', async () => { const user = userEvent.setup() mockTaskAndActivities() diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx index 4d8b99a..e7dea40 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx @@ -22,7 +22,7 @@ import { } from '../../api/documents' import { ApiError, getErrorMessage } from '../../api/errors' import { downloadFile } from '../../api/files' -import { cancelTask, fetchTaskById, updateChecklistItem } from '../../api/tasks' +import { cancelTask, fetchTaskById, updateChecklistItem, type TaskType } from '../../api/tasks' import { adoptWorkerResponseDocuments, fetchTaskWorkerLinkDelivery, @@ -71,6 +71,7 @@ import { type ExternalCompletionSubmission, } from './overlays/ExternalCompletionModal' import { LinkDeliveryConfirmModal } from './overlays/LinkDeliveryConfirmModal' +import { RenewalExecutionModal } from './overlays/RenewalExecutionModal' import { LinkReissueModal, type ReissueSubmission } from './overlays/LinkReissueModal' import { LinkReissuedModal } from './overlays/LinkReissuedModal' import { RejectionReasonModal } from './overlays/RejectionReasonModal' @@ -125,6 +126,12 @@ function formatResponseFileSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)}MB` } +const RENEWAL_TASK_TYPES = new Set([ + 'RECONTRACT', + 'EMPLOYMENT_PERIOD_EXTENSION', + 'STAY_PERIOD_EXTENSION', +]) + async function fetchTaskWorkerLinkDeliveryOrNull( taskId: string, ): Promise { @@ -194,6 +201,7 @@ export function CaseDetailPage() { const contextRequested = searchParams.get('context') === 'open' const [activeTab, setActiveTab] = useState(CASE_TABS[0]) const [moreMenuOpen, setMoreMenuOpen] = useState(false) + const [renewalOverlayOpen, setRenewalOverlayOpen] = useState(false) const [contextDrawerOpen, setContextDrawerOpen] = useState(contextRequested) const [approvalOverlay, setApprovalOverlay] = useState('none') const [completionOverlay, setCompletionOverlay] = useState('none') @@ -898,6 +906,21 @@ export function CaseDetailPage() { 담당자 변경 + {RENEWAL_TASK_TYPES.has(task.task_type) && ( +
  • + +
  • + )} )} @@ -1602,6 +1625,22 @@ export function CaseDetailPage() { onConfirm={handleMarkLinkSent} /> + {task && ( + setRenewalOverlayOpen(false)} + onDownloadDocument={handleDownloadDocument} + onApplied={() => { + refetchTask() + refetchDocuments() + refetchActivities() + refetchReadiness() + }} + /> + )} + {caseId && (caseProjectionStatus === 'loading' || caseProjectionStatus === 'empty') && ( = {}): RenewalExecutionResponse { + return { + request_id: 'R-1', + task_id: 'T-1', + task_status: 'APPROVED', + task_version: 2, + intent: 'STAY_PERIOD_EXTENSION', + workflow_id: 'WF-STY-001', + confidence: 0.9, + scenario: 'generate', + outcome: '문서를 생성했습니다.', + missing_slots: [], + requested_fields: [], + case_signals: [], + generated_documents: [], + worker_message_draft_id: null, + worker_message_draft_version: null, + human_review_required: true, + ...overrides, + } +} + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('RenewalExecutionModal', () => { + it('runs the instruction and shows the outcome', async () => { + const user = userEvent.setup() + vi.mocked(fetch).mockResolvedValue(jsonResponse(response())) + const onApplied = vi.fn() + + render( + , + ) + + await user.type( + screen.getByPlaceholderText('예: 응웬반A 체류기간 연장 준비해줘'), + '체류기간 연장 준비해줘', + ) + await user.click(screen.getByRole('button', { name: '실행' })) + + expect(await screen.findByText('문서를 생성했습니다.')).toBeInTheDocument() + expect(onApplied).toHaveBeenCalledOnce() + const call = vi.mocked(fetch).mock.calls[0] + expect(String(call[0])).toContain('/tasks/T-1/renewal-run') + expect(JSON.parse(String(call[1]?.body))).toEqual({ + instruction: '체류기간 연장 준비해줘', + expected_version: 1, + }) + }) + + it('lets HR answer user-input slots and resubmits with the answers', async () => { + const user = userEvent.setup() + vi.mocked(fetch) + .mockResolvedValueOnce( + jsonResponse( + response({ + task_status: 'NEEDS_INFO', + outcome: '추가 정보가 필요합니다.', + missing_slots: ['wage'], + requested_fields: [{ key: 'wage', source_hint: 'USER_INPUT' }], + task_version: 5, + }), + ), + ) + .mockResolvedValueOnce(jsonResponse(response({ task_version: 6 }))) + + render( + , + ) + + await user.type( + screen.getByPlaceholderText('예: 응웬반A 체류기간 연장 준비해줘'), + '체류기간 연장 준비해줘', + ) + await user.click(screen.getByRole('button', { name: '실행' })) + expect(await screen.findByText('추가 정보가 필요합니다.')).toBeInTheDocument() + + await user.type(screen.getByLabelText('wage'), '2500000') + await user.click(screen.getByRole('button', { name: '답변 제출하고 다시 실행' })) + + expect(await screen.findByText('문서를 생성했습니다.')).toBeInTheDocument() + const secondCall = vi.mocked(fetch).mock.calls[1] + expect(JSON.parse(String(secondCall[1]?.body))).toEqual({ + instruction: '체류기간 연장 준비해줘', + expected_version: 5, + slot_answers: { wage: '2500000' }, + }) + }) + + it('shows an error message when the run fails', async () => { + const user = userEvent.setup() + vi.mocked(fetch).mockResolvedValue( + jsonResponse( + { + timestamp: '2026-08-12T00:00:00Z', + status: 422, + code: 'RENEWAL_NOT_APPLICABLE', + message: 'Renewal 대상 업무가 아닙니다.', + path: '/api/v1/tasks/T-1/renewal-run', + request_id: 'req-1', + field_errors: [], + }, + { status: 422 }, + ), + ) + + render( + , + ) + + await user.type( + screen.getByPlaceholderText('예: 응웬반A 체류기간 연장 준비해줘'), + '체류기간 연장 준비해줘', + ) + await user.click(screen.getByRole('button', { name: '실행' })) + + expect(await screen.findByText('Renewal 대상 업무가 아닙니다.')).toBeInTheDocument() + }) +}) diff --git a/src/pages/CaseDetailPage/overlays/RenewalExecutionModal.tsx b/src/pages/CaseDetailPage/overlays/RenewalExecutionModal.tsx new file mode 100644 index 0000000..9397ff2 --- /dev/null +++ b/src/pages/CaseDetailPage/overlays/RenewalExecutionModal.tsx @@ -0,0 +1,236 @@ +import { useState } from 'react' +import { Modal } from '../../../components/ui/Modal/Modal' +import { StatusLabel } from '../../../components/ui/StatusLabel/StatusLabel' +import { ApiError, getErrorMessage } from '../../../api/errors' +import { runRenewalExecution, type RenewalExecutionResponse } from '../../../api/renewal' +import { TASK_STATUS_LABEL, TASK_STATUS_TONE } from '../../../utils/taskStatus' +import styles from './overlays.module.css' + +export interface RenewalExecutionModalProps { + open: boolean + taskId: string + taskVersion: number + onClose: () => void + onDownloadDocument: (fileId: string, fallbackName: string) => void + onApplied: () => void +} + +export function RenewalExecutionModal({ + open, + taskId, + taskVersion, + onClose, + onDownloadDocument, + onApplied, +}: RenewalExecutionModalProps) { + const [instruction, setInstruction] = useState('') + const [slotAnswers, setSlotAnswers] = useState>({}) + const [result, setResult] = useState(null) + const [running, setRunning] = useState(false) + const [error, setError] = useState(null) + + function handleClose() { + setInstruction('') + setSlotAnswers({}) + setResult(null) + setError(null) + onClose() + } + + async function handleRun() { + const text = instruction.trim() + if (text === '' || running) return + + setRunning(true) + setError(null) + try { + const response = await runRenewalExecution(taskId, { + instruction: text, + expected_version: result?.task_version ?? taskVersion, + }) + setResult(response) + setSlotAnswers({}) + onApplied() + } catch (caught) { + setError( + caught instanceof ApiError ? getErrorMessage(caught) : 'Renewal 실행에 실패했습니다.', + ) + } finally { + setRunning(false) + } + } + + async function handleSubmitAnswers() { + if (!result || running) return + const answers = Object.fromEntries( + Object.entries(slotAnswers).filter(([, value]) => value.trim() !== ''), + ) + if (Object.keys(answers).length === 0) return + + setRunning(true) + setError(null) + try { + const response = await runRenewalExecution(taskId, { + instruction: instruction.trim(), + expected_version: result.task_version, + slot_answers: answers, + }) + setResult(response) + setSlotAnswers({}) + onApplied() + } catch (caught) { + setError( + caught instanceof ApiError ? getErrorMessage(caught) : 'Renewal 실행에 실패했습니다.', + ) + } finally { + setRunning(false) + } + } + + const answerableFields = + result?.requested_fields.filter((field) => field.source_hint === 'USER_INPUT') ?? [] + const nonAnswerableMissingSlots = + result?.missing_slots.filter((slot) => !answerableFields.some((field) => field.key === slot)) ?? + [] + + return ( + +

    + 재계약·연장 처리를 자연어로 요청하면 Agent가 필요정보를 확인하고 문서를 준비합니다. 자동 + 승인·발송은 하지 않습니다. +

    + + {!result && ( +
    +

    요청 내용

    +