From 1126dd31f44e80c90573dfef698b8a334c965c1b Mon Sep 17 00:00:00 2001 From: hywznn Date: Sun, 9 Aug 2026 00:44:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20OCR=20=EA=B2=80?= =?UTF-8?q?=ED=86=A0=20=ED=9D=90=EB=A6=84=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/documentOcr.test.ts | 83 ++++ src/api/documentOcr.ts | 80 ++++ .../DocumentDetailPage.module.css | 12 - .../DocumentDetailPage.test.tsx | 201 ++++++++- .../DocumentDetailPage/DocumentDetailPage.tsx | 27 +- .../DocumentOcrPanel.module.css | 256 ++++++++++++ .../DocumentDetailPage/DocumentOcrPanel.tsx | 384 ++++++++++++++++++ 7 files changed, 1011 insertions(+), 32 deletions(-) create mode 100644 src/api/documentOcr.test.ts create mode 100644 src/api/documentOcr.ts create mode 100644 src/pages/DocumentDetailPage/DocumentOcrPanel.module.css create mode 100644 src/pages/DocumentDetailPage/DocumentOcrPanel.tsx diff --git a/src/api/documentOcr.test.ts b/src/api/documentOcr.test.ts new file mode 100644 index 0000000..f6f3556 --- /dev/null +++ b/src/api/documentOcr.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createDocumentOcrRun, + fetchDocumentOcrRun, + fetchLatestDocumentOcrRun, + reviewDocumentOcrRun, +} from './documentOcr' + +function jsonResponse() { + return new Response( + JSON.stringify({ + ocr_run_id: 'run-1', + document_id: 'document-1', + file_id: 'file-1', + document_type: 'ARC', + status: 'QUEUED', + result: null, + corrected_fields: {}, + error_code: null, + reviewed_by: null, + review_reason: null, + created_at: '2026-08-09T00:00:00Z', + started_at: null, + completed_at: null, + reviewed_at: null, + updated_at: '2026-08-09T00:00:00Z', + version: 0, + already_requested: false, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) +} + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse())) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('document OCR API', () => { + it('starts an OCR run with an idempotency key', async () => { + await createDocumentOcrRun('document/1', 'ocr-request-1') + + const [url, init] = vi.mocked(fetch).mock.calls[0] + expect(String(url)).toContain('/documents/document%2F1/ocr-runs') + expect(init?.method).toBe('POST') + expect(new Headers(init?.headers).get('Idempotency-Key')).toBe('ocr-request-1') + }) + + it('gets one OCR run', async () => { + await fetchDocumentOcrRun('document-1', 'run/1') + + const [url, init] = vi.mocked(fetch).mock.calls[0] + expect(String(url)).toContain('/documents/document-1/ocr-runs/run%2F1') + expect(init?.method).toBeUndefined() + }) + + it('gets the latest OCR run for the document', async () => { + await fetchLatestDocumentOcrRun('document-1') + + const [url] = vi.mocked(fetch).mock.calls[0] + expect(String(url)).toContain('/documents/document-1/ocr-runs/latest') + }) + + it('submits HR corrections and the latest version for review', async () => { + await reviewDocumentOcrRun('document-1', 'run-1', { + expected_version: 2, + decision: 'APPROVE', + corrected_fields: { stay_expiration_date: '2026-12-31' }, + }) + + const [url, init] = vi.mocked(fetch).mock.calls[0] + expect(String(url)).toContain('/documents/document-1/ocr-runs/run-1/review') + expect(init?.method).toBe('POST') + expect(JSON.parse(init?.body as string)).toEqual({ + expected_version: 2, + decision: 'APPROVE', + corrected_fields: { stay_expiration_date: '2026-12-31' }, + }) + }) +}) diff --git a/src/api/documentOcr.ts b/src/api/documentOcr.ts new file mode 100644 index 0000000..b2b99b7 --- /dev/null +++ b/src/api/documentOcr.ts @@ -0,0 +1,80 @@ +import type { DocumentType } from './documents' +import { apiFetch } from './client' + +export type DocumentOcrStatus = + 'QUEUED' | 'RUNNING' | 'READY_FOR_REVIEW' | 'REVIEW_REQUIRED' | 'APPROVED' | 'REJECTED' | 'FAILED' + +export type DocumentOcrReviewDecision = 'APPROVE' | 'REJECT' + +export interface DocumentOcrResult { + matched_template_id: number | null + document_side: 'FRONT' | 'BACK' + fields: Record + field_confidences: Record + review_reasons: string[] +} + +export interface DocumentOcrRunResponse { + ocr_run_id: string + document_id: string + file_id: string + document_type: DocumentType + status: DocumentOcrStatus + result: DocumentOcrResult | null + corrected_fields: Record + error_code: string | null + reviewed_by: string | null + review_reason: string | null + created_at: string + started_at: string | null + completed_at: string | null + reviewed_at: string | null + updated_at: string + version: number + already_requested: boolean +} + +export interface DocumentOcrReviewBody { + expected_version: number + decision: DocumentOcrReviewDecision + reason?: string + corrected_fields: Record +} + +function ocrRunsPath(documentId: string, suffix = '') { + return `/documents/${encodeURIComponent(documentId)}/ocr-runs${suffix}` +} + +export function createDocumentOcrRun( + documentId: string, + idempotencyKey: string, +): Promise { + return apiFetch(ocrRunsPath(documentId), { + method: 'POST', + headers: { 'Idempotency-Key': idempotencyKey }, + }) +} + +export function fetchDocumentOcrRun( + documentId: string, + ocrRunId: string, +): Promise { + return apiFetch( + ocrRunsPath(documentId, `/${encodeURIComponent(ocrRunId)}`), + ) +} + +export function fetchLatestDocumentOcrRun(documentId: string): Promise { + return apiFetch(ocrRunsPath(documentId, '/latest')) +} + +export function reviewDocumentOcrRun( + documentId: string, + ocrRunId: string, + body: DocumentOcrReviewBody, +): Promise { + return apiFetch( + ocrRunsPath(documentId, `/${encodeURIComponent(ocrRunId)}/review`), + { method: 'POST', body: JSON.stringify(body) }, + ) +} diff --git a/src/pages/DocumentDetailPage/DocumentDetailPage.module.css b/src/pages/DocumentDetailPage/DocumentDetailPage.module.css index 6b1c6d2..cd02cd6 100644 --- a/src/pages/DocumentDetailPage/DocumentDetailPage.module.css +++ b/src/pages/DocumentDetailPage/DocumentDetailPage.module.css @@ -108,15 +108,3 @@ color: var(--brand-primary); cursor: pointer; } - -.actionDock { - display: flex; - justify-content: flex-end; - gap: 12px; - margin-top: 16px; - padding: var(--fowoco-spacing-12) var(--fowoco-spacing-20); - background: var(--surface-default); - border: 1px solid var(--border-default); - border-radius: var(--fowoco-radius-10); - box-shadow: var(--shadow-sm); -} diff --git a/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx b/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx index df35387..2820fcb 100644 --- a/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx +++ b/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DocumentItemResponse, DocumentPageResponse } from '../../api/documents' +import type { DocumentOcrRunResponse } from '../../api/documentOcr' import { DocumentDetailPage } from './DocumentDetailPage' function document(overrides: Partial): DocumentItemResponse { @@ -20,16 +21,79 @@ function document(overrides: Partial): DocumentItemRespons const DOCUMENTS: DocumentItemResponse[] = [ document({ worker_document_id: 'D-1', worker_id: 'W-1', display_name: '응웬반A' }), - document({ worker_document_id: 'D-2', worker_id: 'W-2', display_name: '박서준', document_type: 'PERMIT' }), + document({ + worker_document_id: 'D-2', + worker_id: 'W-2', + display_name: '박서준', + document_type: 'PERMIT', + }), ] +function ocrRun( + status: DocumentOcrRunResponse['status'], + overrides: Partial = {}, +): DocumentOcrRunResponse { + return { + ocr_run_id: 'ocr-run-1', + document_id: 'D-1', + file_id: 'file-1', + document_type: 'ARC', + status, + result: null, + corrected_fields: {}, + error_code: null, + reviewed_by: null, + review_reason: null, + created_at: '2026-08-09T00:00:00Z', + started_at: null, + completed_at: null, + reviewed_at: null, + updated_at: '2026-08-09T00:00:00Z', + version: 0, + already_requested: false, + ...overrides, + } +} + +const OCR_READY = ocrRun('READY_FOR_REVIEW', { + result: { + matched_template_id: 12, + document_side: 'FRONT', + fields: { + alien_registration_number: '900101-5000000', + visa_type: 'E-9', + stay_expiration_date: '2026-12-01', + }, + field_confidences: { + alien_registration_number: 0.98, + visa_type: 0.93, + stay_expiration_date: 0.81, + }, + review_reasons: ['체류 만료일을 원본과 대조해 주세요.'], + }, + completed_at: '2026-08-09T00:00:02Z', + version: 2, +}) + function jsonResponse(body: unknown, init: ResponseInit = {}) { - return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' }, ...init }) + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + ...init, + }) } function errorResponse(status: number, code: string, message: string) { return jsonResponse( - { timestamp: '2026-07-27T01:23:45Z', status, code, message, path: '/api/v1/documents', request_id: 'req-1', field_errors: [] }, + { + timestamp: '2026-07-27T01:23:45Z', + status, + code, + message, + path: '/api/v1/documents', + request_id: 'req-1', + field_errors: [], + }, { status }, ) } @@ -55,6 +119,7 @@ beforeEach(() => { }) afterEach(() => { + vi.useRealTimers() vi.restoreAllMocks() vi.unstubAllGlobals() }) @@ -90,6 +155,9 @@ describe('DocumentDetailPage', () => { ] vi.mocked(fetch) .mockResolvedValueOnce(jsonResponse(pageResponse(fileDocuments))) + .mockResolvedValueOnce( + errorResponse(404, 'DOCUMENT_OCR_RUN_NOT_FOUND', 'OCR 실행 이력을 찾을 수 없습니다.'), + ) .mockResolvedValueOnce( new Response(new Blob(['pdf']), { headers: { 'Content-Disposition': 'attachment; filename="arc.pdf"' }, @@ -97,9 +165,7 @@ describe('DocumentDetailPage', () => { ) const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:file-1') const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) - const clickAnchor = vi - .spyOn(HTMLAnchorElement.prototype, 'click') - .mockImplementation(() => {}) + const clickAnchor = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) renderPage('D-1') await user.click(await screen.findByRole('button', { name: '원본 다운로드' })) @@ -107,7 +173,10 @@ describe('DocumentDetailPage', () => { expect(createObjectUrl).toHaveBeenCalledTimes(1) expect(clickAnchor).toHaveBeenCalledTimes(1) expect(revokeObjectUrl).toHaveBeenCalledWith('blob:file-1') - expect(String(vi.mocked(fetch).mock.calls[1][0])).toContain('/files/file-1/content') + const downloadCall = vi + .mocked(fetch) + .mock.calls.find(([url]) => String(url).includes('/files/file-1/content')) + expect(downloadCall).toBeDefined() }) it('shows an empty state when the documentId does not match any document', async () => { @@ -117,14 +186,126 @@ describe('DocumentDetailPage', () => { expect(await screen.findByText('서류를 찾을 수 없습니다')).toBeInTheDocument() }) - it('does not fabricate approval or rejection without a versioned API', async () => { + it('does not offer OCR review when no file is connected', async () => { vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS))) renderPage('D-1') await screen.findByRole('heading', { name: '외국인등록증' }) expect(screen.getByText('서류 없음')).toBeInTheDocument() - expect(screen.getByRole('button', { name: '반려' })).toBeDisabled() - expect(screen.getByRole('button', { name: '상세 확인' })).toBeDisabled() + expect(screen.getByText('연결된 파일이 없어 OCR을 실행할 수 없습니다.')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'OCR 검토 완료' })).not.toBeInTheDocument() + }) + + it('runs OCR, polls until ready, submits only HR corrections, and marks review complete', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + const fileDocuments = [ + document({ + worker_document_id: 'D-1', + display_name: '응웬반A', + submission_status: 'SUBMITTED', + file_id: 'file-1', + }), + ] + vi.mocked(fetch) + .mockResolvedValueOnce(jsonResponse(pageResponse(fileDocuments))) + .mockResolvedValueOnce( + errorResponse(404, 'DOCUMENT_OCR_RUN_NOT_FOUND', 'OCR 실행 이력을 찾을 수 없습니다.'), + ) + .mockResolvedValueOnce(jsonResponse(ocrRun('QUEUED'))) + .mockResolvedValueOnce(jsonResponse(OCR_READY)) + .mockResolvedValueOnce( + jsonResponse( + ocrRun('APPROVED', { + ...OCR_READY, + status: 'APPROVED', + corrected_fields: { stay_expiration_date: '2026-12-31' }, + reviewed_at: '2026-08-09T00:00:04Z', + version: 3, + }), + ), + ) + renderPage('D-1') + + await user.click(await screen.findByRole('button', { name: 'OCR 실행' })) + expect(await screen.findByText('OCR 결과를 확인하는 중입니다.')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(1600) + const expiryInput = await screen.findByDisplayValue('2026-12-01') + await user.clear(expiryInput) + await user.type(expiryInput, '2026-12-31') + await user.click(screen.getByRole('button', { name: 'OCR 검토 완료' })) + + expect(await screen.findByText('OCR 검토를 완료했습니다.')).toBeInTheDocument() + expect(screen.getByText(/근로자 정보는 자동 변경되지 않습니다/)).toBeInTheDocument() + + const calls = vi.mocked(fetch).mock.calls + const createCall = calls.find( + ([url, init]) => String(url).endsWith('/documents/D-1/ocr-runs') && init?.method === 'POST', + ) + expect(new Headers(createCall?.[1]?.headers).get('Idempotency-Key')).toBeTruthy() + const reviewCall = calls.find(([url]) => String(url).includes('/ocr-run-1/review')) + expect(JSON.parse(reviewCall?.[1]?.body as string)).toEqual({ + expected_version: 2, + decision: 'APPROVE', + corrected_fields: { stay_expiration_date: '2026-12-31' }, + }) + }) + + it('shows a preparing message when the OCR feature returns 503', async () => { + const fileDocuments = [ + document({ worker_document_id: 'D-1', submission_status: 'SUBMITTED', file_id: 'file-1' }), + ] + vi.mocked(fetch) + .mockResolvedValueOnce(jsonResponse(pageResponse(fileDocuments))) + .mockResolvedValueOnce( + errorResponse(503, 'DOCUMENT_OCR_DISABLED', 'OCR 기능이 아직 활성화되지 않았습니다.'), + ) + renderPage('D-1') + + expect(await screen.findByText(/OCR 기능 준비 중입니다/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'OCR 실행' })).not.toBeInTheDocument() + }) + + it('requires a reason and submits no corrected fields when OCR is rejected', async () => { + const user = userEvent.setup() + const fileDocuments = [ + document({ worker_document_id: 'D-1', submission_status: 'SUBMITTED', file_id: 'file-1' }), + ] + vi.mocked(fetch) + .mockResolvedValueOnce(jsonResponse(pageResponse(fileDocuments))) + .mockResolvedValueOnce(jsonResponse(OCR_READY)) + .mockResolvedValueOnce( + jsonResponse( + ocrRun('REJECTED', { + ...OCR_READY, + status: 'REJECTED', + review_reason: '원본 이미지가 흐립니다.', + reviewed_at: '2026-08-09T00:00:04Z', + version: 3, + }), + ), + ) + renderPage('D-1') + + const rejectButton = await screen.findByRole('button', { name: '반려' }) + expect(rejectButton).toBeDisabled() + await user.type( + screen.getByPlaceholderText('반려할 때만 입력해 주세요.'), + '원본 이미지가 흐립니다.', + ) + await user.click(rejectButton) + + expect(await screen.findByText(/OCR 결과를 반려했습니다/)).toBeInTheDocument() + const reviewCall = vi + .mocked(fetch) + .mock.calls.find(([url]) => String(url).includes('/ocr-run-1/review')) + expect(JSON.parse(reviewCall?.[1]?.body as string)).toEqual({ + expected_version: 2, + decision: 'REJECT', + reason: '원본 이미지가 흐립니다.', + corrected_fields: {}, + }) }) it('shows a loading state', () => { diff --git a/src/pages/DocumentDetailPage/DocumentDetailPage.tsx b/src/pages/DocumentDetailPage/DocumentDetailPage.tsx index 3fee531..a05c01d 100644 --- a/src/pages/DocumentDetailPage/DocumentDetailPage.tsx +++ b/src/pages/DocumentDetailPage/DocumentDetailPage.tsx @@ -10,6 +10,7 @@ import { useApiQuery } from '../../hooks/useApiQuery' import { useToastStore } from '../../store/toastStore' import { saveBlobAsFile } from '../../utils/fileDownload' import { getDocumentViewModel } from '../../view-models/documentViewModel' +import { DocumentOcrPanel } from './DocumentOcrPanel' import styles from './DocumentDetailPage.module.css' export function DocumentDetailPage() { @@ -20,7 +21,12 @@ export function DocumentDetailPage() { // GET /api/v1/documents/{id} 단건 조회가 없어서(#57 조사 결과), 목록을 통째로 받아 // worker_document_id로 찾는다. - const { status: fetchStatus, data, error, refetch } = useApiQuery(useCallback(() => fetchDocuments({ size: 100 }), [])) + const { + status: fetchStatus, + data, + error, + refetch, + } = useApiQuery(useCallback(() => fetchDocuments({ size: 100 }), [])) const document = data?.items.find((item) => item.worker_document_id === documentId) ?? null if (fetchStatus === 'loading') { @@ -53,7 +59,11 @@ export function DocumentDetailPage() { if (!document) { return (
- +
) } @@ -124,14 +134,11 @@ export function DocumentDetailPage() { -
- - -
+ ) } diff --git a/src/pages/DocumentDetailPage/DocumentOcrPanel.module.css b/src/pages/DocumentDetailPage/DocumentOcrPanel.module.css new file mode 100644 index 0000000..c550427 --- /dev/null +++ b/src/pages/DocumentDetailPage/DocumentOcrPanel.module.css @@ -0,0 +1,256 @@ +.panel { + margin-top: 16px; + padding: var(--fowoco-spacing-20) var(--fowoco-spacing-24); + background: var(--surface-default); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-10); + box-shadow: var(--shadow-sm); +} + +.panel h2 { + margin: 0; + font-size: 18px; + color: var(--text-primary); +} + +.panelHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.panelHeader p, +.notice { + margin: 6px 0 0; + font-size: 12px; + line-height: 19px; + color: var(--text-secondary); +} + +.error { + margin-top: 14px; + padding: 10px 12px; + background: var(--fowoco-red-50); + border-radius: var(--fowoco-radius-6); + font-size: 12px; + color: var(--fowoco-red-600); +} + +.stateAction { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 16px; + padding: 16px; + background: var(--surface-subtle); + border-radius: var(--fowoco-radius-8); +} + +.stateAction .notice { + margin: 0; +} + +.processing { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + padding: 16px; + background: var(--fowoco-teal-50); + border-radius: var(--fowoco-radius-8); +} + +.processing strong { + font-size: 13px; + color: var(--text-primary); +} + +.processing p { + margin: 3px 0 0; + font-size: 12px; + color: var(--text-secondary); +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid var(--fowoco-teal-100); + border-top-color: var(--brand-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.reviewReasons { + margin-top: 16px; + padding: 12px 14px; + background: var(--fowoco-amber-50); + border-radius: var(--fowoco-radius-8); +} + +.reviewReasons strong { + font-size: 12px; + color: var(--text-primary); +} + +.reviewReasons ul { + margin: 6px 0 0; + padding-left: 18px; + font-size: 12px; + line-height: 19px; + color: var(--text-secondary); +} + +.fieldList { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 16px; + margin-top: 16px; +} + +.fieldRow { + display: flex; + min-width: 0; + flex-direction: column; + gap: 6px; +} + +.fieldRow > span { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.fieldRow strong { + font-size: 12px; + color: var(--text-primary); +} + +.fieldRow small { + font-size: 11px; + color: var(--text-secondary); +} + +.fieldRow .originalValue { + line-height: 17px; + color: var(--text-secondary); +} + +.fieldRow input, +.fieldRow output { + box-sizing: border-box; + width: 100%; + min-height: 40px; + padding: 9px 11px; + background: var(--surface-default); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-6); + font: inherit; + font-size: 13px; + color: var(--text-primary); +} + +.fieldRow output { + background: var(--surface-subtle); +} + +.fieldRow input:focus-visible, +.rejectReason textarea:focus-visible { + outline: 0; + box-shadow: var(--shadow-focus); +} + +.reviewArea { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--border-default); +} + +.scopeNote { + margin: 14px 0 0; + font-size: 12px; + line-height: 19px; + color: var(--text-secondary); +} + +.reviewArea > p { + margin: 0; + font-size: 12px; + line-height: 19px; + color: var(--text-secondary); +} + +.reviewArea > .correctionWarning { + margin-top: 8px; + color: var(--fowoco-red-600); +} + +.rejectReason { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 12px; +} + +.rejectReason span { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.rejectReason textarea { + min-height: 72px; + padding: 10px 11px; + resize: vertical; + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-6); + font: inherit; + font-size: 13px; + color: var(--text-primary); +} + +.reviewActions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 12px; +} + +.reviewedNotice { + margin: 16px 0 0; + padding: 12px 14px; + background: var(--fowoco-green-50); + border-radius: var(--fowoco-radius-6); + font-size: 12px; + color: var(--fowoco-green-600); +} + +@media (max-width: 720px) { + .panel { + padding: var(--fowoco-spacing-16); + } + + .fieldList { + grid-template-columns: 1fr; + } + + .stateAction, + .reviewActions { + align-items: stretch; + flex-direction: column; + } +} + +@media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } +} diff --git a/src/pages/DocumentDetailPage/DocumentOcrPanel.tsx b/src/pages/DocumentDetailPage/DocumentOcrPanel.tsx new file mode 100644 index 0000000..5c56fef --- /dev/null +++ b/src/pages/DocumentDetailPage/DocumentOcrPanel.tsx @@ -0,0 +1,384 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + createDocumentOcrRun, + fetchDocumentOcrRun, + fetchLatestDocumentOcrRun, + reviewDocumentOcrRun, + type DocumentOcrRunResponse, + type DocumentOcrStatus, +} from '../../api/documentOcr' +import type { DocumentType } from '../../api/documents' +import { ApiError, getErrorMessage } from '../../api/errors' +import { Button } from '../../components/ui/Button/Button' +import { StatusLabel, type StatusTone } from '../../components/ui/StatusLabel/StatusLabel' +import styles from './DocumentOcrPanel.module.css' + +const POLL_INTERVAL_MS = 1500 +const PROCESSING_STATUSES: DocumentOcrStatus[] = ['QUEUED', 'RUNNING'] +const REVIEWABLE_STATUSES: DocumentOcrStatus[] = ['READY_FOR_REVIEW', 'REVIEW_REQUIRED'] + +const STATUS_PRESENTATION: Record = { + QUEUED: { label: '실행 대기', tone: 'info' }, + RUNNING: { label: '추출 중', tone: 'info' }, + READY_FOR_REVIEW: { label: '검토 가능', tone: 'success' }, + REVIEW_REQUIRED: { label: '확인 필요', tone: 'warning' }, + APPROVED: { label: '검토 완료', tone: 'success' }, + REJECTED: { label: '반려', tone: 'critical' }, + FAILED: { label: '실행 실패', tone: 'critical' }, +} + +const FIELD_LABEL: Record = { + passport_number: '여권번호', + surname: '성', + given_names: '이름', + date_of_birth: '생년월일', + sex: '성별', + passport_issue_date: '여권 발급일', + passport_expiry_date: '여권 만료일', + alien_registration_number: '외국인등록번호', + visa_type: '체류 자격', + stay_expiration_date: '체류 만료일', + residence_address_1: '체류지 주소', +} + +const CORRECTABLE_FIELDS: Record<'PASSPORT_COPY' | 'ARC', Set> = { + PASSPORT_COPY: new Set([ + 'passport_number', + 'surname', + 'given_names', + 'date_of_birth', + 'sex', + 'passport_issue_date', + 'passport_expiry_date', + ]), + ARC: new Set([ + 'alien_registration_number', + 'visa_type', + 'stay_expiration_date', + 'residence_address_1', + ]), +} + +type PanelState = 'loading' | 'empty' | 'ready' | 'disabled' | 'error' + +interface DocumentOcrPanelProps { + documentId: string + documentType: DocumentType + fileId: string | null +} + +function messageFor(error: unknown) { + return error instanceof ApiError + ? getErrorMessage(error) + : 'OCR 상태를 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.' +} + +export function DocumentOcrPanel({ documentId, documentType, fileId }: DocumentOcrPanelProps) { + const supported = documentType === 'PASSPORT_COPY' || documentType === 'ARC' + const [panelState, setPanelState] = useState('loading') + const [run, setRun] = useState(null) + const [fieldDrafts, setFieldDrafts] = useState>({}) + const [rejectReason, setRejectReason] = useState('') + const [requestError, setRequestError] = useState(null) + const [busyAction, setBusyAction] = useState<'create' | 'approve' | 'reject' | null>(null) + const requestKeyRef = useRef(null) + + const applyRun = useCallback((next: DocumentOcrRunResponse) => { + setRun(next) + setPanelState('ready') + setRequestError(null) + if (next.result) { + setFieldDrafts({ ...next.result.fields, ...next.corrected_fields }) + } + }, []) + + const loadLatest = useCallback(async () => { + if (!supported || !fileId) return + setPanelState('loading') + setRequestError(null) + try { + applyRun(await fetchLatestDocumentOcrRun(documentId)) + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + setPanelState('empty') + setRun(null) + } else if (error instanceof ApiError && error.status === 503) { + setPanelState('disabled') + setRun(null) + } else { + setPanelState('error') + setRequestError(messageFor(error)) + } + } + }, [applyRun, documentId, fileId, supported]) + + useEffect(() => { + if (!supported || !fileId) return + void loadLatest() + }, [fileId, loadLatest, supported]) + + useEffect(() => { + if (!run || !PROCESSING_STATUSES.includes(run.status)) return + + let cancelled = false + const timer = window.setTimeout(async () => { + try { + const next = await fetchDocumentOcrRun(documentId, run.ocr_run_id) + if (!cancelled) applyRun(next) + } catch (error) { + if (cancelled) return + if (error instanceof ApiError && error.status === 503) { + setPanelState('disabled') + } else { + setPanelState('error') + setRequestError(messageFor(error)) + } + } + }, POLL_INTERVAL_MS) + + return () => { + cancelled = true + window.clearTimeout(timer) + } + }, [applyRun, documentId, run]) + + const changedFields = useMemo(() => { + if (!run?.result || (documentType !== 'PASSPORT_COPY' && documentType !== 'ARC')) return {} + const allowed = CORRECTABLE_FIELDS[documentType] + return Object.fromEntries( + Object.entries(fieldDrafts) + .filter( + ([field, value]) => allowed.has(field) && value.trim() !== run.result?.fields[field], + ) + .map(([field, value]) => [field, value.trim()]), + ) + }, [documentType, fieldDrafts, run]) + const correctableFields = + documentType === 'PASSPORT_COPY' || documentType === 'ARC' + ? CORRECTABLE_FIELDS[documentType] + : new Set() + const hasEmptyCorrection = + Boolean(run?.result) && + Object.entries(fieldDrafts).some( + ([field, value]) => correctableFields.has(field) && value.trim() === '', + ) + + async function handleCreate(forceNew = false) { + if (forceNew) requestKeyRef.current = null + requestKeyRef.current ??= crypto.randomUUID() + setBusyAction('create') + setRequestError(null) + try { + applyRun(await createDocumentOcrRun(documentId, requestKeyRef.current)) + } catch (error) { + if (error instanceof ApiError && error.status === 503) { + setPanelState('disabled') + } else { + setRequestError(messageFor(error)) + } + } finally { + setBusyAction(null) + } + } + + async function handleReview(decision: 'APPROVE' | 'REJECT') { + if (!run || !REVIEWABLE_STATUSES.includes(run.status)) return + if (decision === 'REJECT' && !rejectReason.trim()) return + setBusyAction(decision === 'APPROVE' ? 'approve' : 'reject') + setRequestError(null) + try { + const reviewed = await reviewDocumentOcrRun(documentId, run.ocr_run_id, { + expected_version: run.version, + decision, + reason: decision === 'REJECT' ? rejectReason.trim() : undefined, + corrected_fields: decision === 'APPROVE' ? changedFields : {}, + }) + applyRun(reviewed) + } catch (error) { + setRequestError(messageFor(error)) + } finally { + setBusyAction(null) + } + } + + if (!supported) { + return ( +
+

문서 OCR

+

현재 여권 사본과 외국인등록증만 OCR을 지원합니다.

+
+ ) + } + + if (!fileId) { + return ( +
+

문서 OCR

+

연결된 파일이 없어 OCR을 실행할 수 없습니다.

+
+ ) + } + + return ( +
+
+
+

문서 OCR

+

원본에서 정보를 추출한 뒤 담당자가 수정하고 검토 상태를 확정합니다.

+
+ {run && ( + + {STATUS_PRESENTATION[run.status].label} + + )} +
+ + {requestError && ( +
+ {requestError} +
+ )} + + {panelState === 'loading' && ( +

최신 OCR 상태를 확인하고 있습니다.

+ )} + + {panelState === 'disabled' && ( +

+ OCR 기능 준비 중입니다. 기능이 활성화되면 다시 시도해 주세요. +

+ )} + + {panelState === 'error' && ( +
+

OCR 상태를 불러오지 못했습니다.

+ +
+ )} + + {panelState === 'empty' && ( +
+

아직 이 문서의 OCR 실행 이력이 없습니다.

+ +
+ )} + + {panelState === 'ready' && run && PROCESSING_STATUSES.includes(run.status) && ( +
+
+ )} + + {panelState === 'ready' && run?.status === 'FAILED' && ( +
+

+ OCR 실행을 완료하지 못했습니다{run.error_code ? ` · ${run.error_code}` : ''} +

+ +
+ )} + + {panelState === 'ready' && run?.result && ( + <> + {run.result.review_reasons.length > 0 && ( +
+ 원본 대조가 필요한 이유 +
    + {run.result.review_reasons.map((reason) => ( +
  • {reason}
  • + ))} +
+
+ )} + +
+ {Object.entries(run.result.fields).map(([field, originalValue]) => { + const confidence = run.result?.field_confidences[field] + const editable = correctableFields.has(field) + return ( + + ) + })} +
+ +

+ 승인해도 근로자 정보는 자동 변경되지 않습니다. 이 화면의 OCR 검토만 완료됩니다. +

+ + {REVIEWABLE_STATUSES.includes(run.status) && ( +
+ {hasEmptyCorrection && ( +

+ 추출 필드는 빈 값으로 승인할 수 없습니다. +

+ )} +