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
52 changes: 52 additions & 0 deletions src/api/renewal.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
}

// fowoco/server RenewalExecutionController 기준. slot_answers는 requested_fields 중
// source_hint가 "USER_INPUT"인 key만 허용 — 그 외(OCR 등)를 보내면 422로 거부된다.
export function runRenewalExecution(
taskId: string,
body: RenewalExecutionBody,
): Promise<RenewalExecutionResponse> {
return apiFetch<RenewalExecutionResponse>(`/tasks/${encodeURIComponent(taskId)}/renewal-run`, {
method: 'POST',
body: JSON.stringify(body),
})
}
5 changes: 1 addition & 4 deletions src/api/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TaskDetailResponse> {
return apiFetch<TaskDetailResponse>('/tasks', { method: 'POST', body: JSON.stringify(body) })
Expand Down
23 changes: 23 additions & 0 deletions src/pages/CaseDetailPage/CaseDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -900,11 +900,34 @@ describe('CaseDetailPage', () => {
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()
Expand Down
41 changes: 40 additions & 1 deletion src/pages/CaseDetailPage/CaseDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -125,6 +126,12 @@ function formatResponseFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
}

const RENEWAL_TASK_TYPES = new Set<TaskType>([
'RECONTRACT',
'EMPLOYMENT_PERIOD_EXTENSION',
'STAY_PERIOD_EXTENSION',
])

async function fetchTaskWorkerLinkDeliveryOrNull(
taskId: string,
): Promise<WorkerLinkDeliveryResponse | null> {
Expand Down Expand Up @@ -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<ApprovalOverlay>('none')
const [completionOverlay, setCompletionOverlay] = useState<CompletionOverlay>('none')
Expand Down Expand Up @@ -898,6 +906,21 @@ export function CaseDetailPage() {
담당자 변경
</button>
</li>
{RENEWAL_TASK_TYPES.has(task.task_type) && (
<li role="presentation">
<button
type="button"
role="menuitem"
className={styles.moreMenuItem}
onClick={() => {
setMoreMenuOpen(false)
setRenewalOverlayOpen(true)
}}
>
Renewal 실행
</button>
</li>
)}
</ul>
)}
</div>
Expand Down Expand Up @@ -1602,6 +1625,22 @@ export function CaseDetailPage() {
onConfirm={handleMarkLinkSent}
/>

{task && (
<RenewalExecutionModal
open={renewalOverlayOpen}
taskId={task.task_id}
taskVersion={task.version}
onClose={() => setRenewalOverlayOpen(false)}
onDownloadDocument={handleDownloadDocument}
onApplied={() => {
refetchTask()
refetchDocuments()
refetchActivities()
refetchReadiness()
}}
/>
)}

<Drawer open={contextDrawerOpen} onClose={handleCloseContext} title="관련 Context">
{caseId && (caseProjectionStatus === 'loading' || caseProjectionStatus === 'empty') && (
<EmptyState
Expand Down
160 changes: 160 additions & 0 deletions src/pages/CaseDetailPage/overlays/RenewalExecutionModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RenewalExecutionResponse } from '../../../api/renewal'
import { RenewalExecutionModal } from './RenewalExecutionModal'

function jsonResponse(body: unknown, init: ResponseInit = {}) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
...init,
})
}

function response(overrides: Partial<RenewalExecutionResponse> = {}): 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(
<RenewalExecutionModal
open
taskId="T-1"
taskVersion={1}
onClose={vi.fn()}
onDownloadDocument={vi.fn()}
onApplied={onApplied}
/>,
)

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(
<RenewalExecutionModal
open
taskId="T-1"
taskVersion={4}
onClose={vi.fn()}
onDownloadDocument={vi.fn()}
onApplied={vi.fn()}
/>,
)

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(
<RenewalExecutionModal
open
taskId="T-1"
taskVersion={1}
onClose={vi.fn()}
onDownloadDocument={vi.fn()}
onApplied={vi.fn()}
/>,
)

await user.type(
screen.getByPlaceholderText('예: 응웬반A 체류기간 연장 준비해줘'),
'체류기간 연장 준비해줘',
)
await user.click(screen.getByRole('button', { name: '실행' }))

expect(await screen.findByText('Renewal 대상 업무가 아닙니다.')).toBeInTheDocument()
})
})
Loading