diff --git a/src/api/stayVerifications.ts b/src/api/stayVerifications.ts new file mode 100644 index 0000000..8ad3843 --- /dev/null +++ b/src/api/stayVerifications.ts @@ -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 { + return apiFetch('/stay-verifications') +} + +export async function ensureStayVerification( + workerId: string, +): Promise { + 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 { + return apiFetch(`/stay-verifications/${encodeURIComponent(stayVerificationId)}`, { + method: 'PATCH', + body: JSON.stringify(body), + }) +} diff --git a/src/api/workers.ts b/src/api/workers.ts index 1f43735..a30edd6 100644 --- a/src/api/workers.ts +++ b/src/api/workers.ts @@ -89,3 +89,42 @@ export function patchWorker(workerId: string, body: WorkerPatchBody): Promise { + return apiFetch(`/workers/${encodeURIComponent(workerId)}/archive-eligibility`) +} + +export function archiveWorker( + workerId: string, + reason: string, + expectedVersion: number, +): Promise { + return apiFetch(`/workers/${encodeURIComponent(workerId)}/archive`, { + method: 'POST', + body: JSON.stringify({ reason, expected_version: expectedVersion }), + }) +} diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.module.css b/src/pages/WorkerDetailPage/WorkerDetailPage.module.css index 5ae5a75..389c08b 100644 --- a/src/pages/WorkerDetailPage/WorkerDetailPage.module.css +++ b/src/pages/WorkerDetailPage/WorkerDetailPage.module.css @@ -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); diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx b/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx index 90d3af5..98904d4 100644 --- a/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx +++ b/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx @@ -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 = {}) { @@ -86,6 +87,30 @@ function task(overrides: Partial = {}): TaskSummaryResponse } } +function stayVerification( + overrides: Partial = {}, +): 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 = {}, documents: DocumentItemResponse[] = [], @@ -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[] = [] + 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) + 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') diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.tsx b/src/pages/WorkerDetailPage/WorkerDetailPage.tsx index 056a8fa..5aeb4c9 100644 --- a/src/pages/WorkerDetailPage/WorkerDetailPage.tsx +++ b/src/pages/WorkerDetailPage/WorkerDetailPage.tsx @@ -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() { @@ -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 ( @@ -85,7 +87,20 @@ export function WorkerDetailPage() {

{worker.display_name}

{!stayExpiry.missing && stayExpiry.tone !== 'neutral' && ( - {stayExpiry.relative} 체류만료 + + {stayExpiry.expired + ? `기록상 ${stayExpiry.relative} 경과 · 긴급 확인` + : `${stayExpiry.relative} 체류만료`} + + )} + {stayExpiry.expired && ( + )}

@@ -207,6 +222,21 @@ export function WorkerDetailPage() { onClose={() => setEditModalOpen(false)} onSaved={refetch} /> + + { + setStayVerificationOpen(false) + refetch() + refetchDocuments() + }} + onRegisterEvidence={() => { + setStayVerificationOpen(false) + setRegisterModalOpen(true) + }} + /> ) } diff --git a/src/pages/WorkerDetailPage/overlays/StayVerificationModal.module.css b/src/pages/WorkerDetailPage/overlays/StayVerificationModal.module.css new file mode 100644 index 0000000..e403ed5 --- /dev/null +++ b/src/pages/WorkerDetailPage/overlays/StayVerificationModal.module.css @@ -0,0 +1,174 @@ +.notice { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 16px; + border: 1px solid var(--status-warning); + border-radius: var(--fowoco-radius-8); + background: var(--fowoco-amber-50); + color: var(--text-primary); + font-size: 13px; +} + +.summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin: 18px 0; +} + +.summary div { + padding: 12px; + border-radius: var(--fowoco-radius-6); + background: var(--surface-subtle); +} + +.summary dt { + font-size: 12px; + color: var(--text-secondary); +} + +.summary dd { + margin: 6px 0 0; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.fieldset { + margin: 0 0 18px; + padding: 0; + border: 0; +} + +.fieldset legend, +.fieldLabel { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + margin: 0 0 16px; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); +} + +.optionGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.radioLabel { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 0 12px; + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-6); + font-size: 13px; + color: var(--text-primary); + cursor: pointer; +} + +.dateGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.input { + box-sizing: border-box; + width: 100%; + min-height: 42px; + padding: 9px 12px; + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-6); + background: var(--surface-default); + color: var(--text-primary); + font: inherit; +} + +.textarea { + min-height: 84px; + resize: vertical; +} + +.helpRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-weight: 400; + color: var(--status-warning); +} + +.inlineButton { + flex: 0 0 auto; + padding: 0; + border: 0; + background: transparent; + color: var(--brand-primary); + font: inherit; + font-weight: 600; + text-decoration: underline; + cursor: pointer; +} + +.nextAction { + display: flex; + flex-direction: column; + gap: 5px; + padding: 13px 15px; + border-radius: var(--fowoco-radius-6); + background: var(--fowoco-teal-50); + font-size: 13px; + color: var(--text-primary); +} + +.error, +.success { + margin: 12px 0 0; + font-size: 12px; +} + +.error { + color: var(--status-critical); +} +.success { + color: var(--fowoco-green-600); +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 18px; +} + +.archiveSection { + margin-top: 20px; + padding-top: 18px; + border-top: 1px solid var(--border-default); +} + +.archiveSection h3 { + margin: 0 0 8px; + font-size: 15px; +} + +.archiveSection p, +.archiveSection li { + font-size: 12px; + color: var(--text-secondary); +} + +@media (max-width: 640px) { + .summary, + .optionGrid, + .dateGrid { + grid-template-columns: 1fr; + } +} diff --git a/src/pages/WorkerDetailPage/overlays/StayVerificationModal.tsx b/src/pages/WorkerDetailPage/overlays/StayVerificationModal.tsx new file mode 100644 index 0000000..6a4d539 --- /dev/null +++ b/src/pages/WorkerDetailPage/overlays/StayVerificationModal.tsx @@ -0,0 +1,423 @@ +import { useEffect, useMemo, useState, type FormEvent } from 'react' +import type { DocumentItemResponse } from '../../../api/documents' +import { ApiError, getErrorMessage } from '../../../api/errors' +import { + ensureStayVerification, + updateStayVerification, + type StayVerificationResponse, + type StayVerificationStatus, + type StayVerificationUpdateBody, +} from '../../../api/stayVerifications' +import { + archiveWorker, + fetchWorkerArchiveEligibility, + type WorkerArchiveBlocker, + type WorkerArchiveEligibilityResponse, + type WorkerResponse, +} from '../../../api/workers' +import { Button } from '../../../components/ui/Button/Button' +import { EmptyState } from '../../../components/ui/EmptyState/EmptyState' +import { Modal } from '../../../components/ui/Modal/Modal' +import styles from './StayVerificationModal.module.css' + +export interface StayVerificationModalProps { + open: boolean + worker: WorkerResponse + documents: DocumentItemResponse[] + onClose: () => void + onRegisterEvidence: () => void +} + +const STATUS_OPTIONS: { value: StayVerificationStatus; label: string }[] = [ + { value: 'APPROVED', label: '연장 승인 완료' }, + { value: 'APPLICATION_PENDING', label: '기한 전 신청·심사 중' }, + { value: 'UNKNOWN', label: '신청 여부 불명확' }, + { value: 'NOT_APPLIED', label: '미신청 상태로 기간 경과' }, + { value: 'EMPLOYMENT_ENDED', label: '출국 또는 고용 종료 확인' }, +] + +const NEXT_ACTION: Record = { + APPROVED: '새 체류 만료일을 근로자 정보에 반영합니다.', + APPLICATION_PENDING: '외부기관 결과를 기다리고 재확인일에 다시 확인합니다.', + UNKNOWN: '공식기관 또는 보유 서류로 신청 여부를 확인합니다.', + NOT_APPLIED: '고위험 HR 검토 상태를 유지하고 대응 방법을 확인합니다.', + EMPLOYMENT_ENDED: '고용변동 Workflow 후보를 검토합니다. 근로자 상태는 자동 변경되지 않습니다.', +} + +const BLOCKER_LABEL: Record = { + ACTIVE_EMPLOYMENT_STATUS: '근무상태가 아직 재직 또는 휴직입니다.', + OPEN_TASK: '완료되지 않은 업무가 있습니다.', + PENDING_APPROVAL: '처리되지 않은 승인 요청이 있습니다.', + ACTIVE_WORKER_LINK: '아직 유효한 근로자 보안 링크가 있습니다.', + ALREADY_ARCHIVED: '이미 안전 보관된 근로자입니다.', +} + +function today(): string { + return localDateString(new Date()) +} + +function tomorrow(): string { + const date = new Date() + date.setDate(date.getDate() + 1) + return localDateString(date) +} + +function localDateString(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function readableError(error: unknown): string { + return error instanceof ApiError + ? getErrorMessage(error) + : '요청을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.' +} + +export function StayVerificationModal({ + open, + worker, + documents, + onClose, + onRegisterEvidence, +}: StayVerificationModalProps) { + const [verification, setVerification] = useState(null) + const [loadState, setLoadState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') + const [loadError, setLoadError] = useState('') + const [status, setStatus] = useState('UNKNOWN') + const [evidenceDocumentId, setEvidenceDocumentId] = useState('') + const [newExpiryDate, setNewExpiryDate] = useState('') + const [appliedAt, setAppliedAt] = useState(today()) + const [recheckDate, setRecheckDate] = useState(tomorrow()) + const [note, setNote] = useState('') + const [submitError, setSubmitError] = useState('') + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [archiveEligibility, setArchiveEligibility] = + useState(null) + const [archiveReason, setArchiveReason] = useState('고용 종료 확인 후 운영 목록에서 안전 보관') + const [archiving, setArchiving] = useState(false) + const [archived, setArchived] = useState(false) + + useEffect(() => { + if (!open) return + let cancelled = false + setLoadState('loading') + setLoadError('') + setSubmitError('') + setSaved(false) + setArchived(false) + setArchiveEligibility(null) + + ensureStayVerification(worker.worker_id) + .then((result) => { + if (cancelled) return + if (!result) { + setLoadState('error') + setLoadError('체류기간 경과 확인 대상을 만들 수 없습니다. 만료일을 다시 확인해 주세요.') + return + } + setVerification(result) + setStatus(result.verification_status) + setEvidenceDocumentId( + result.approval_result_document_id ?? result.extension_receipt_document_id ?? '', + ) + setNewExpiryDate(result.new_stay_expiry_date ?? '') + setAppliedAt(result.extension_applied_at ?? today()) + setRecheckDate(result.recheck_date ?? tomorrow()) + setNote(result.official_consultation_note ?? '') + setLoadState('success') + }) + .catch((error: unknown) => { + if (cancelled) return + setLoadState('error') + setLoadError(readableError(error)) + }) + + return () => { + cancelled = true + } + }, [open, worker.worker_id]) + + const evidenceRequired = status === 'APPROVED' || status === 'APPLICATION_PENDING' + const noteRequired = status === 'NOT_APPLIED' || status === 'EMPLOYMENT_ENDED' + const formError = useMemo(() => { + if (evidenceRequired && !evidenceDocumentId) return '등록된 증빙 서류를 선택해 주세요.' + if (status === 'APPROVED' && !newExpiryDate) return '승인된 새 체류 만료일을 입력해 주세요.' + if ( + status === 'APPROVED' && + verification && + newExpiryDate <= verification.source_stay_expiry_date + ) { + return '새 체류 만료일은 기존 만료일보다 뒤여야 합니다.' + } + if (status === 'APPLICATION_PENDING' && (!appliedAt || !recheckDate)) { + return '신청일과 재확인일을 입력해 주세요.' + } + if (status === 'APPLICATION_PENDING' && recheckDate <= today()) { + return '재확인일은 내일 이후로 입력해 주세요.' + } + if (noteRequired && !note.trim()) return '확인 근거 또는 담당자 메모를 입력해 주세요.' + return '' + }, [ + appliedAt, + evidenceDocumentId, + evidenceRequired, + newExpiryDate, + note, + noteRequired, + recheckDate, + status, + verification, + ]) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + if (!verification || formError) return + setSaving(true) + setSubmitError('') + + const body: StayVerificationUpdateBody = { + status, + expected_version: verification.version, + } + if (note.trim()) body.official_consultation_note = note.trim() + if (status === 'APPROVED') { + body.new_stay_expiry_date = newExpiryDate + body.approval_result_document_id = evidenceDocumentId + } + if (status === 'APPLICATION_PENDING') { + body.extension_applied_at = appliedAt + body.recheck_date = recheckDate + body.extension_receipt_document_id = evidenceDocumentId + } + if (status === 'EMPLOYMENT_ENDED') { + body.employment_end_confirmed_at = new Date().toISOString() + } + + try { + const updated = await updateStayVerification(verification.stay_verification_id, body) + setVerification(updated) + setSaved(true) + if (updated.verification_status === 'EMPLOYMENT_ENDED') { + try { + const eligibility = await fetchWorkerArchiveEligibility(worker.worker_id) + setArchiveEligibility(eligibility) + } catch { + setSubmitError('확인 결과는 저장했지만 안전 보관 가능 여부를 불러오지 못했습니다.') + } + } else { + setArchiveEligibility(null) + } + } catch (error) { + setSubmitError(readableError(error)) + } finally { + setSaving(false) + } + } + + async function handleArchive() { + if (!archiveEligibility?.archivable || !archiveReason.trim()) return + setArchiving(true) + setSubmitError('') + try { + await archiveWorker(worker.worker_id, archiveReason.trim(), archiveEligibility.worker_version) + setArchived(true) + } catch (error) { + setSubmitError(readableError(error)) + } finally { + setArchiving(false) + } + } + + return ( + + {loadState === 'loading' && ( + + )} + {loadState === 'error' && ( + + )} + {loadState === 'success' && verification && ( +

+
+ 기록상 체류 만료일이 지났습니다. + 이 화면은 법적 체류 상태나 퇴사 여부를 자동으로 확정하지 않습니다. +
+ +
+
+
근로자
+
{worker.display_name}
+
+
+
기록상 만료일
+
{verification.source_stay_expiry_date}
+
+
+ +
+ HR 확인 결과 +
+ {STATUS_OPTIONS.map((option) => ( + + ))} +
+
+ + {status === 'APPROVED' && ( + + )} + + {status === 'APPLICATION_PENDING' && ( +
+ + +
+ )} + + {evidenceRequired && ( + + )} + +