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
66 changes: 66 additions & 0 deletions src/api/stayVerifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { apiFetch } from './client'

export type StayVerificationStatus =
'APPROVED' | 'APPLICATION_PENDING' | 'UNKNOWN' | 'NOT_APPLIED' | 'EMPLOYMENT_ENDED'

export interface StayVerificationResponse {
stay_verification_id: string
worker_id: string
worker_display_name: string
source_stay_expiry_date: string
verification_status: StayVerificationStatus
status_checked_at: string | null
extension_applied_at: string | null
extension_receipt_document_id: string | null
approval_result_document_id: string | null
new_stay_expiry_date: string | null
official_consultation_note: string | null
employment_end_confirmed_at: string | null
recheck_date: string | null
employment_change_candidate_available: boolean
suggested_workflow_id: string | null
version: number
}

export interface StayVerificationUpdateBody {
status: StayVerificationStatus
extension_applied_at?: string
extension_receipt_document_id?: string
approval_result_document_id?: string
new_stay_expiry_date?: string
official_consultation_note?: string
employment_end_confirmed_at?: string
recheck_date?: string
expected_version: number
}

export function scanExpiredStayWorkers(): Promise<{ created_count: number }> {
return apiFetch('/stay-verifications/scan', { method: 'POST' })
}

export function fetchStayVerifications(): Promise<StayVerificationResponse[]> {
return apiFetch('/stay-verifications')
}

export async function ensureStayVerification(
workerId: string,
): Promise<StayVerificationResponse | null> {
let cases = await fetchStayVerifications()
let verification = cases.find((item) => item.worker_id === workerId)
if (verification) return verification

await scanExpiredStayWorkers()
cases = await fetchStayVerifications()
verification = cases.find((item) => item.worker_id === workerId)
return verification ?? null
}

export function updateStayVerification(
stayVerificationId: string,
body: StayVerificationUpdateBody,
): Promise<StayVerificationResponse> {
return apiFetch(`/stay-verifications/${encodeURIComponent(stayVerificationId)}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
39 changes: 39 additions & 0 deletions src/api/workers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,42 @@ export function patchWorker(workerId: string, body: WorkerPatchBody): Promise<Wo
body: JSON.stringify(body),
})
}

export type WorkerArchiveBlocker =
| 'ACTIVE_EMPLOYMENT_STATUS'
| 'OPEN_TASK'
| 'PENDING_APPROVAL'
| 'ACTIVE_WORKER_LINK'
| 'ALREADY_ARCHIVED'

export interface WorkerArchiveEligibilityResponse {
worker_id: string
archivable: boolean
blockers: WorkerArchiveBlocker[]
worker_version: number
}

export interface WorkerArchiveResponse {
worker_id: string
archived_at: string
archived_by: string
archive_reason: string
worker_version: number
}

export function fetchWorkerArchiveEligibility(
workerId: string,
): Promise<WorkerArchiveEligibilityResponse> {
return apiFetch(`/workers/${encodeURIComponent(workerId)}/archive-eligibility`)
}

export function archiveWorker(
workerId: string,
reason: string,
expectedVersion: number,
): Promise<WorkerArchiveResponse> {
return apiFetch(`/workers/${encodeURIComponent(workerId)}/archive`, {
method: 'POST',
body: JSON.stringify({ reason, expected_version: expectedVersion }),
})
}
12 changes: 12 additions & 0 deletions src/pages/WorkerDetailPage/WorkerDetailPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@
color: var(--text-secondary);
}

.verificationButton {
min-height: 34px;
padding: 0 14px;
border: 1px solid var(--status-critical);
border-radius: var(--fowoco-radius-6);
background: var(--surface-default);
color: var(--status-critical);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}

.sectionCard {
margin-top: 16px;
padding: var(--fowoco-spacing-16) var(--fowoco-spacing-24);
Expand Down
165 changes: 165 additions & 0 deletions src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DocumentItemResponse, WorkerDocumentResponse } from '../../api/documents'
import type { TaskSummaryResponse } from '../../api/tasks'
import type { WorkerResponse } from '../../api/workers'
import type { StayVerificationResponse } from '../../api/stayVerifications'
import { WorkerDetailPage } from './WorkerDetailPage'

function jsonResponse(body: unknown, init: ResponseInit = {}) {
Expand Down Expand Up @@ -86,6 +87,30 @@ function task(overrides: Partial<TaskSummaryResponse> = {}): TaskSummaryResponse
}
}

function stayVerification(
overrides: Partial<StayVerificationResponse> = {},
): StayVerificationResponse {
return {
stay_verification_id: 'SV-1',
worker_id: 'W-018',
worker_display_name: '쩐티B',
source_stay_expiry_date: '2026-08-01',
verification_status: 'UNKNOWN',
status_checked_at: null,
extension_applied_at: null,
extension_receipt_document_id: null,
approval_result_document_id: null,
new_stay_expiry_date: null,
official_consultation_note: null,
employment_end_confirmed_at: null,
recheck_date: null,
employment_change_candidate_available: false,
suggested_workflow_id: null,
version: 0,
...overrides,
}
}

function mockWorkerAndDocuments(
workerOverrides: Partial<WorkerResponse> = {},
documents: DocumentItemResponse[] = [],
Expand Down Expand Up @@ -208,6 +233,146 @@ describe('WorkerDetailPage', () => {
expect(await screen.findByText('진행 중인 업무가 없습니다')).toBeInTheDocument()
})

it('opens the urgent verification flow without declaring a legal status', async () => {
const user = userEvent.setup()
vi.mocked(fetch).mockImplementation((input) => {
const url = String(input)
if (url.includes('/stay-verifications')) {
return Promise.resolve(jsonResponse([stayVerification()]))
}
if (url.includes('/documents')) {
return Promise.resolve(jsonResponse({ items: [], page: 0, size: 100, total_elements: 0 }))
}
if (url.includes('/tasks')) {
return Promise.resolve(jsonResponse({ items: [], page: 0, size: 20, total_elements: 0 }))
}
return Promise.resolve(jsonResponse(worker({ stay_expiry_date: '2026-08-01' })))
})
renderPage('W-018')

expect(await screen.findByText(/기록상 D\+/)).toHaveTextContent('긴급 확인')
await user.click(screen.getByRole('button', { name: '체류상태 확인 시작' }))

expect(await screen.findByRole('dialog', { name: '체류상태 긴급 확인' })).toBeInTheDocument()
expect(
screen.getByText(/법적 체류 상태나 퇴사 여부를 자동으로 확정하지 않습니다/),
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: '운영 목록에서 안전 보관' }),
).not.toBeInTheDocument()
})

it('requires an approval document and saves the approved expiry date', async () => {
const user = userEvent.setup()
const bodies: Record<string, unknown>[] = []
vi.mocked(fetch).mockImplementation((input, init) => {
const url = String(input)
const method = init?.method ?? 'GET'
if (url.includes('/stay-verifications/SV-1') && method === 'PATCH') {
bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>)
return Promise.resolve(
jsonResponse(
stayVerification({
verification_status: 'APPROVED',
approval_result_document_id: 'D-1',
new_stay_expiry_date: '2027-08-01',
version: 1,
}),
),
)
}
if (url.includes('/stay-verifications')) {
return Promise.resolve(jsonResponse([stayVerification()]))
}
if (url.includes('/documents')) {
return Promise.resolve(
jsonResponse({ items: [document()], page: 0, size: 100, total_elements: 1 }),
)
}
if (url.includes('/tasks')) {
return Promise.resolve(jsonResponse({ items: [], page: 0, size: 20, total_elements: 0 }))
}
return Promise.resolve(jsonResponse(worker({ stay_expiry_date: '2026-08-01' })))
})
renderPage('W-018')

await user.click(await screen.findByRole('button', { name: '체류상태 확인 시작' }))
await screen.findByRole('dialog', { name: '체류상태 긴급 확인' })
await user.click(screen.getByRole('radio', { name: '연장 승인 완료' }))

const save = screen.getByRole('button', { name: '확인 결과 저장' })
expect(save).toBeDisabled()
await user.selectOptions(screen.getByLabelText('승인 결과 증빙'), 'D-1')
await user.type(screen.getByLabelText('승인된 새 체류 만료일'), '2027-08-01')
await user.click(save)

expect(await screen.findByText('확인 결과와 근거를 저장했습니다.')).toBeInTheDocument()
expect(bodies).toContainEqual({
status: 'APPROVED',
expected_version: 0,
new_stay_expiry_date: '2027-08-01',
approval_result_document_id: 'D-1',
})
})

it('shows archive blockers only after HR confirms employment ended', async () => {
const user = userEvent.setup()
let workerGetCount = 0
vi.mocked(fetch).mockImplementation((input, init) => {
const url = String(input)
const method = init?.method ?? 'GET'
if (url.includes('/stay-verifications/SV-1') && method === 'PATCH') {
return Promise.resolve(
jsonResponse(
stayVerification({
verification_status: 'EMPLOYMENT_ENDED',
official_consultation_note: 'HR이 출국 사실 확인',
employment_end_confirmed_at: '2026-08-17T03:00:00Z',
employment_change_candidate_available: true,
suggested_workflow_id: 'WF-CHG-001',
version: 1,
}),
),
)
}
if (url.includes('/archive-eligibility')) {
return Promise.resolve(
jsonResponse({
worker_id: 'W-018',
archivable: false,
blockers: ['ACTIVE_EMPLOYMENT_STATUS'],
worker_version: 1,
}),
)
}
if (url.includes('/stay-verifications')) {
return Promise.resolve(jsonResponse([stayVerification()]))
}
if (url.includes('/documents')) {
return Promise.resolve(jsonResponse({ items: [], page: 0, size: 100, total_elements: 0 }))
}
if (url.includes('/tasks')) {
return Promise.resolve(jsonResponse({ items: [], page: 0, size: 20, total_elements: 0 }))
}
workerGetCount += 1
return Promise.resolve(jsonResponse(worker({ stay_expiry_date: '2026-08-01' })))
})
renderPage('W-018')

await user.click(await screen.findByRole('button', { name: '체류상태 확인 시작' }))
await user.click(await screen.findByRole('radio', { name: '출국 또는 고용 종료 확인' }))
expect(screen.queryByText('운영 목록 안전 보관')).not.toBeInTheDocument()
await user.type(screen.getByLabelText('확인 메모 (필수)'), 'HR이 출국 사실 확인')
await user.click(screen.getByRole('button', { name: '확인 결과 저장' }))

expect(await screen.findByText('운영 목록 안전 보관')).toBeInTheDocument()
expect(screen.getByText('근무상태가 아직 재직 또는 휴직입니다.')).toBeInTheDocument()
expect(workerGetCount).toBe(1)
expect(
screen.queryByRole('button', { name: '운영 목록에서 안전 보관' }),
).not.toBeInTheDocument()
})

it('shows a loading state', () => {
vi.mocked(fetch).mockReturnValue(new Promise(() => {}))
renderPage('W-018')
Expand Down
32 changes: 31 additions & 1 deletion src/pages/WorkerDetailPage/WorkerDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { TASK_STATUS_LABEL, TASK_STATUS_TONE } from '../../utils/taskStatus'
import { getDocumentViewModel } from '../../view-models/documentViewModel'
import { getOperationalDateViewModel } from '../../view-models/dateViewModel'
import { RegisterDocumentModal } from './overlays/RegisterDocumentModal'
import { StayVerificationModal } from './overlays/StayVerificationModal'
import styles from './WorkerDetailPage.module.css'

export function WorkerDetailPage() {
Expand All @@ -33,6 +34,7 @@ export function WorkerDetailPage() {

const [registerModalOpen, setRegisterModalOpen] = useState(false)
const [editModalOpen, setEditModalOpen] = useState(false)
const [stayVerificationOpen, setStayVerificationOpen] = useState(false)

if (status === 'loading') {
return (
Expand Down Expand Up @@ -85,7 +87,20 @@ export function WorkerDetailPage() {
<div className={styles.headerRow}>
<h1 className={styles.title}>{worker.display_name}</h1>
{!stayExpiry.missing && stayExpiry.tone !== 'neutral' && (
<StatusLabel tone={stayExpiry.tone}>{stayExpiry.relative} 체류만료</StatusLabel>
<StatusLabel tone={stayExpiry.tone}>
{stayExpiry.expired
? `기록상 ${stayExpiry.relative} 경과 · 긴급 확인`
: `${stayExpiry.relative} 체류만료`}
</StatusLabel>
)}
{stayExpiry.expired && (
<button
type="button"
className={styles.verificationButton}
onClick={() => setStayVerificationOpen(true)}
>
체류상태 확인 시작
</button>
)}
</div>
<p className={styles.meta}>
Expand Down Expand Up @@ -207,6 +222,21 @@ export function WorkerDetailPage() {
onClose={() => setEditModalOpen(false)}
onSaved={refetch}
/>

<StayVerificationModal
open={stayVerificationOpen}
worker={worker}
documents={workerDocuments}
onClose={() => {
setStayVerificationOpen(false)
refetch()
refetchDocuments()
}}
onRegisterEvidence={() => {
setStayVerificationOpen(false)
setRegisterModalOpen(true)
}}
/>
</div>
)
}
Loading