From 37ff7a85925a59c202b25cf551ed003cd038e963 Mon Sep 17 00:00:00 2001 From: hywznn Date: Sun, 9 Aug 2026 00:37:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20=EA=B7=BC?= =?UTF-8?q?=EB=A1=9C=EC=9E=90=20=EB=AA=85=EB=8B=A8=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OnboardingImportPage.module.css | 276 ++++++++- .../OnboardingImportPage.test.tsx | 229 ++++++-- .../OnboardingImportPage.tsx | 544 ++++++++++++++---- .../onboardingImportData.ts | 95 ++- 4 files changed, 979 insertions(+), 165 deletions(-) diff --git a/src/pages/OnboardingImportPage/OnboardingImportPage.module.css b/src/pages/OnboardingImportPage/OnboardingImportPage.module.css index d28c186..762756c 100644 --- a/src/pages/OnboardingImportPage/OnboardingImportPage.module.css +++ b/src/pages/OnboardingImportPage/OnboardingImportPage.module.css @@ -54,11 +54,31 @@ } .content { - max-width: 720px; + max-width: 880px; margin: 0 auto; padding: 32px 24px 64px; } +.errorPanel { + margin-bottom: 20px; + padding: 14px 16px; + color: var(--fowoco-red-600); + background: var(--fowoco-red-50); + border: 1px solid var(--status-critical); + border-radius: var(--fowoco-radius-8); +} + +.errorPanel strong, +.errorPanel p { + margin: 0; + font-size: 13px; + line-height: 20px; +} + +.errorPanel p { + margin-top: 2px; +} + .title { margin: 0; font-size: 24px; @@ -174,12 +194,232 @@ color: var(--text-primary); } +.fileMeta { + margin: -8px 0 0; + font-size: 12px; + color: var(--text-secondary); +} + .chooseFileButton { position: relative; z-index: 1; pointer-events: none; } +/* 03 열 연결 */ + +.titleRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; +} + +.mappingList { + max-height: 440px; + margin-top: 24px; + overflow-y: auto; + background: var(--surface-default); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-10); +} + +.mappingRow { + display: grid; + grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr); + align-items: end; + gap: 16px; + padding: 14px 18px; + border-bottom: 1px solid var(--border-default); +} + +.mappingRow:last-child { + border-bottom: 0; +} + +.mappingRow > div, +.mappingRow label { + display: flex; + min-width: 0; + flex-direction: column; + gap: 5px; +} + +.mappingRow span, +.mappingRow label > span { + font-size: 11px; + color: var(--text-secondary); +} + +.mappingRow strong { + overflow: hidden; + font-size: 14px; + color: var(--text-primary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.mappingRow select { + width: 100%; + height: 38px; + padding: 0 32px 0 12px; + 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); +} + +.mappingRow select:focus-visible, +.rowErrorFields input:focus-visible { + outline: 0; + box-shadow: var(--shadow-focus); +} + +.mappingArrow { + align-self: center; + text-align: center; +} + +.inlineWarning { + margin: 12px 0 0; + font-size: 12px; + color: var(--fowoco-amber-600); +} + +/* 04 행 검토 */ + +.rowList { + display: flex; + max-height: 430px; + flex-direction: column; + gap: 10px; + margin-top: 18px; + padding-right: 4px; + overflow-y: auto; +} + +.reviewRow { + padding: 14px 16px; + background: var(--surface-default); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-8); +} + +.reviewRowInvalid { + border-color: var(--status-critical); +} + +.reviewRowHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.reviewRowHeader > div { + display: flex; + min-width: 0; + align-items: baseline; + gap: 10px; +} + +.reviewRowHeader span { + flex: 0 0 auto; + font-size: 11px; + color: var(--text-secondary); +} + +.reviewRowHeader strong { + overflow: hidden; + font-size: 14px; + color: var(--text-primary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.rowErrorFields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--border-default); +} + +.rowErrorFields > label:not(.excludeControl) { + display: flex; + min-width: 0; + flex-direction: column; + gap: 5px; +} + +.rowErrorFields label > span { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.rowErrorFields input[type='text'], +.rowErrorFields input:not([type]) { + width: 100%; + height: 38px; + padding: 0 11px; + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-6); + font: inherit; + font-size: 13px; + color: var(--text-primary); +} + +.rowErrorFields input:disabled { + background: var(--surface-subtle); + color: var(--text-secondary); +} + +.rowErrorFields small { + font-size: 11px; + line-height: 17px; + color: var(--fowoco-red-600); +} + +.excludeControl { + display: flex; + grid-column: 1 / -1; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); +} + +.excludeControl input { + width: 16px; + height: 16px; + accent-color: var(--brand-primary); +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + margin-top: 16px; +} + +.pagination span { + min-width: 64px; + text-align: center; + font-size: 12px; + color: var(--text-secondary); +} + +.reviewActions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + /* 03 자료 분석 중 */ .analysisList { @@ -278,6 +518,40 @@ } @media (max-width: 560px) { + .header { + padding: 16px 20px; + } + + .trust { + display: none; + } + + .content { + padding: 24px 16px 48px; + } + + .mappingRow { + grid-template-columns: 1fr; + } + + .mappingArrow { + display: none; + } + + .rowErrorFields { + grid-template-columns: 1fr; + } + + .actionRow { + align-items: stretch; + flex-direction: column-reverse; + gap: 10px; + } + + .reviewActions { + flex-direction: column; + } + .resultGrid { grid-template-columns: 1fr; } diff --git a/src/pages/OnboardingImportPage/OnboardingImportPage.test.tsx b/src/pages/OnboardingImportPage/OnboardingImportPage.test.tsx index ebaf163..1f2560f 100644 --- a/src/pages/OnboardingImportPage/OnboardingImportPage.test.tsx +++ b/src/pages/OnboardingImportPage/OnboardingImportPage.test.tsx @@ -2,9 +2,116 @@ import { render, screen } from '@testing-library/react' 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 { WorkerImportResponse } from '../../api/workerImports' import { OnboardingImportPage } from './OnboardingImportPage' import { isOnboardingImportPending, markOnboardingImportPending } from './onboardingImportStorage' +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + ...init, + }) +} + +const UPLOADED: WorkerImportResponse = { + import_id: 'import-1', + source_file_id: 'file-1', + status: 'UPLOADED', + source_headers: ['이름', '국적', '체류만료일'], + mappings: {}, + total_rows: 2, + valid_rows: 0, + invalid_rows: 0, + excluded_rows: 0, + committed_rows: 0, + source_file_expires_at: '2026-08-16T00:00:00Z', + version: 0, + rows: [], + page: 0, + size: 100, +} + +const MAPPED: WorkerImportResponse = { + ...UPLOADED, + status: 'MAPPED', + mappings: { + 이름: 'display_name', + 국적: 'nationality_code', + 체류만료일: 'stay_expiry_date', + }, + version: 1, +} + +const REVIEW_REQUIRED: WorkerImportResponse = { + ...MAPPED, + status: 'REVIEW_REQUIRED', + valid_rows: 1, + invalid_rows: 1, + version: 2, + rows: [ + { + row_number: 2, + source_values: { 이름: '응웬반A', 국적: 'VN', 체류만료일: '2026-10-10' }, + override_values: {}, + normalized_values: { + display_name: '응웬반A', + nationality_code: 'VN', + stay_expiry_date: '2026-10-10', + }, + status: 'VALID', + errors: [], + worker_id: null, + version: 1, + }, + { + row_number: 3, + source_values: { 이름: '쩐티B', 국적: 'VN', 체류만료일: '2026/11/01' }, + override_values: {}, + normalized_values: { + display_name: '쩐티B', + nationality_code: 'VN', + stay_expiry_date: '2026/11/01', + }, + status: 'INVALID', + errors: [ + { + field: 'stay_expiry_date', + code: 'INVALID_DATE', + message: '날짜는 YYYY-MM-DD 형식이어야 합니다.', + }, + ], + worker_id: null, + version: 1, + }, + ], +} + +const PATCHED: WorkerImportResponse = { + ...REVIEW_REQUIRED, + status: 'MAPPED', + valid_rows: 1, + invalid_rows: 0, + excluded_rows: 1, + version: 3, +} + +const READY: WorkerImportResponse = { + ...PATCHED, + status: 'READY', + version: 4, + rows: [REVIEW_REQUIRED.rows[0], { ...REVIEW_REQUIRED.rows[1], status: 'EXCLUDED', errors: [] }], +} + +const COMMITTED: WorkerImportResponse = { + ...READY, + status: 'COMMITTED', + valid_rows: 0, + committed_rows: 1, + version: 5, + rows: [{ ...READY.rows[0], status: 'COMMITTED', worker_id: 'worker-1' }, READY.rows[1]], +} + function renderPage() { render( @@ -12,18 +119,24 @@ function renderPage() { } /> dashboard screen

} /> workers screen

} /> - documents screen

} /> + new task screen

} />
, ) } +async function moveToUpload(user: ReturnType) { + await user.click(screen.getByRole('button', { name: /기존 데이터 이전/ })) + await user.click(screen.getByRole('button', { name: '다음 →' })) +} + beforeEach(() => { markOnboardingImportPending() + vi.stubGlobal('fetch', vi.fn()) }) afterEach(() => { - vi.useRealTimers() + vi.unstubAllGlobals() }) describe('OnboardingImportPage', () => { @@ -59,43 +172,91 @@ describe('OnboardingImportPage', () => { expect(await screen.findByText('workers screen')).toBeInTheDocument() }) - it('walks through the full migrate → upload → analyze → review → result flow', async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }) + it('connects upload → mapping → validation → exclusion → commit with latest versions', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(jsonResponse(UPLOADED, { status: 201 })) + .mockResolvedValueOnce(jsonResponse(MAPPED)) + .mockResolvedValueOnce(jsonResponse(REVIEW_REQUIRED)) + .mockResolvedValueOnce(jsonResponse(PATCHED)) + .mockResolvedValueOnce(jsonResponse(READY)) + .mockResolvedValueOnce(jsonResponse(COMMITTED)) const user = userEvent.setup() renderPage() + await moveToUpload(user) - // Step 1: choose migrate - await user.click(screen.getByRole('button', { name: /기존 데이터 이전/ })) - await user.click(screen.getByRole('button', { name: '다음 →' })) + const file = new File(['이름,국적,체류만료일'], 'workers.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }) + await user.upload(screen.getByLabelText('근로자 명단 파일'), file) + await user.click(screen.getByRole('button', { name: '파일 확인 →' })) - // Step 2: upload a file - expect(screen.getByText('근로자 파일을 올려주세요.')).toBeInTheDocument() - const file = new File(['content'], 'workers.xlsx', { type: 'application/vnd.ms-excel' }) - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement - await user.upload(fileInput, file) - expect(screen.getByText('workers.xlsx')).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: '자료 분석 시작 →' })) - - // Step 3: analysis auto-advances after all stages complete - expect(screen.getByText('자료를 분석하고 있습니다.')).toBeInTheDocument() - await vi.advanceTimersByTimeAsync(3000) - expect(await screen.findByText('근로자·문서 후보를 검토합니다.')).toBeInTheDocument() - - // Step 4: resolve the three blocked candidates - const confirmButtons = screen.getAllByRole('button', { name: '확인 완료' }) - for (const button of confirmButtons) { - await user.click(button) - } - const registerButton = screen.getByRole('button', { name: '등록 확정 →' }) - expect(registerButton).toBeEnabled() - await user.click(registerButton) - - // Step 5: finish to dashboard - expect(await screen.findByText('4명 등록을 완료했습니다.')).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: '대시보드로 이동' })) + expect(await screen.findByText('파일의 열을 연결해 주세요.')).toBeInTheDocument() + expect(screen.getByText('3 / 5 · 열 연결')).toBeInTheDocument() + expect(screen.getByDisplayValue('이름')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: '저장하고 행 검증 →' })) - expect(await screen.findByText('dashboard screen')).toBeInTheDocument() - expect(isOnboardingImportPending()).toBe(false) + expect(await screen.findByText('검증 결과를 확인해 주세요.')).toBeInTheDocument() + expect(screen.getByText('날짜는 YYYY-MM-DD 형식이어야 합니다.')).toBeInTheDocument() + await user.click(screen.getByRole('checkbox', { name: '3행 등록 제외' })) + await user.click(screen.getByRole('button', { name: '수정·제외 저장 후 재검증' })) + + expect(await screen.findByRole('button', { name: '정상 1명 등록 →' })).toBeEnabled() + await user.click(screen.getByRole('button', { name: '정상 1명 등록 →' })) + + expect(await screen.findByText('1명 등록을 완료했습니다.')).toBeInTheDocument() + + const calls = vi.mocked(fetch).mock.calls + expect(calls).toHaveLength(6) + expect(calls[0][1]?.body).toBeInstanceOf(FormData) + expect(new Headers(calls[0][1]?.headers).get('Idempotency-Key')).toBeTruthy() + expect(JSON.parse(calls[1][1]?.body as string)).toMatchObject({ expected_version: 0 }) + expect(JSON.parse(calls[2][1]?.body as string)).toEqual({ expected_version: 1 }) + expect(JSON.parse(calls[3][1]?.body as string)).toEqual({ + expected_version: 2, + rows: [ + { + row_number: 3, + excluded: true, + values: { stay_expiry_date: '2026/11/01' }, + }, + ], + }) + expect(JSON.parse(calls[4][1]?.body as string)).toEqual({ expected_version: 3 }) + expect(JSON.parse(calls[5][1]?.body as string)).toEqual({ + expected_version: 4, + selected_row_numbers: [], + }) + expect(new Headers(calls[5][1]?.headers).get('Idempotency-Key')).toBeTruthy() + }) + + it('shows the Server upload error and keeps the selected file retryable', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + jsonResponse( + { + timestamp: '2026-08-09T00:00:00Z', + status: 415, + code: 'IMPORT_FILE_TYPE_UNSUPPORTED', + message: 'CSV 또는 XLSX 파일만 사용할 수 있습니다.', + path: '/api/v1/imports', + request_id: 'request-1', + field_errors: [], + }, + { status: 415 }, + ), + ) + const user = userEvent.setup() + renderPage() + await moveToUpload(user) + + const file = new File(['invalid'], 'workers.csv', { type: 'text/csv' }) + await user.upload(screen.getByLabelText('근로자 명단 파일'), file) + await user.click(screen.getByRole('button', { name: '파일 확인 →' })) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'CSV 또는 XLSX 파일만 사용할 수 있습니다.', + ) + expect(screen.getByText('workers.csv')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '파일 확인 →' })).toBeEnabled() }) it('shows step progress in the header', () => { diff --git a/src/pages/OnboardingImportPage/OnboardingImportPage.tsx b/src/pages/OnboardingImportPage/OnboardingImportPage.tsx index 526e3e6..950b291 100644 --- a/src/pages/OnboardingImportPage/OnboardingImportPage.tsx +++ b/src/pages/OnboardingImportPage/OnboardingImportPage.tsx @@ -1,28 +1,42 @@ -import { useEffect, useRef, useState } from 'react' +import { useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' +import { + commitWorkerImport, + createWorkerImport, + fetchWorkerImport, + patchWorkerImportRows, + retryWorkerImport, + saveWorkerImportMappings, + validateWorkerImport, + type WorkerImportField, + type WorkerImportResponse, +} from '../../api/workerImports' +import { ApiError, getErrorMessage } from '../../api/errors' import { Button } from '../../components/ui/Button/Button' -import { StatusLabel, type StatusTone } from '../../components/ui/StatusLabel/StatusLabel' +import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel' import { useToastStore } from '../../store/toastStore' import { - ANALYSIS_STAGES, - INITIAL_REVIEW_CANDIDATES, - REVIEW_STATUS_LABEL, + buildSuggestedMappings, + getEffectiveRowValue, + getWorkerImportFieldLabel, + IMPORT_STATUS_LABEL, START_OPTIONS, SUPPORTED_FORMATS, - type ReviewCandidate, + WORKER_IMPORT_FIELDS, type StartMode, } from './onboardingImportData' import { clearOnboardingImportPending } from './onboardingImportStorage' import styles from './OnboardingImportPage.module.css' const STEP_COUNT = 5 -const STEP_TITLES = ['시작 방식', '파일 업로드', '자료 분석 중', '검토 및 연결', '등록 결과'] +const STEP_TITLES = ['시작 방식', '파일 업로드', '열 연결', '행 검토', '등록 결과'] + +type BusyAction = 'upload' | 'mapping' | 'rows' | 'commit' | 'page' | null -const REVIEW_STATUS_TONE: Record = { - ready: 'success', - 'needs-info': 'warning', - duplicate: 'critical', - 'doc-type': 'warning', +function errorMessage(error: unknown) { + return error instanceof ApiError + ? getErrorMessage(error) + : '요청을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.' } export function OnboardingImportPage() { @@ -31,12 +45,22 @@ export function OnboardingImportPage() { const [step, setStep] = useState(1) const [startMode, setStartMode] = useState(null) - const [fileName, setFileName] = useState(null) - const [analysisDone, setAnalysisDone] = useState(0) - const [candidates, setCandidates] = useState(INITIAL_REVIEW_CANDIDATES) + const [selectedFile, setSelectedFile] = useState(null) + const [workerImport, setWorkerImport] = useState(null) + const [mappings, setMappings] = useState>({}) + const [rowDrafts, setRowDrafts] = useState>>({}) + const [excludedRowNumbers, setExcludedRowNumbers] = useState([]) + const [busyAction, setBusyAction] = useState(null) + const [requestError, setRequestError] = useState(null) const fileInputRef = useRef(null) + const uploadKeyRef = useRef(null) + const commitKeyRef = useRef(null) - const blockedCandidateCount = candidates.filter((c) => c.status !== 'ready').length + const displayNameMapped = Object.values(mappings).includes('display_name') + const invalidRows = workerImport?.rows.filter((row) => row.status === 'INVALID') ?? [] + const canMoveToNextPage = workerImport + ? (workerImport.page + 1) * workerImport.size < workerImport.total_rows + : false function finishOnboarding(destination: string, message?: string) { clearOnboardingImportPending() @@ -48,39 +72,157 @@ export function OnboardingImportPage() { if (startMode === 'migrate') { setStep(2) } else if (startMode === 'manual') { - finishOnboarding('/workers', 'AI에게 업무 요청으로 근로자를 등록해 보세요.') + finishOnboarding('/workers', '근로자 목록에서 직접 등록할 수 있습니다.') } else { finishOnboarding('/dashboard') } } function handleFileChosen(files: FileList | null) { - const file = files?.[0] - if (file) setFileName(file.name) + const file = files?.[0] ?? null + setSelectedFile(file) + setWorkerImport(null) + setRequestError(null) + uploadKeyRef.current = file ? crypto.randomUUID() : null + commitKeyRef.current = null } - // 데모: 실제 파일 파싱 없이 분석 단계를 순서대로 흉내낸다. - useEffect(() => { - if (step !== 3) return - setAnalysisDone(0) - const timer = setInterval(() => { - setAnalysisDone((prev) => { - const next = prev + 1 - if (next >= ANALYSIS_STAGES.length) { - clearInterval(timer) - setTimeout(() => setStep(4), 400) - } - return next + async function handleUpload() { + if (!selectedFile) return + setBusyAction('upload') + setRequestError(null) + try { + uploadKeyRef.current ??= crypto.randomUUID() + const response = await createWorkerImport(selectedFile, uploadKeyRef.current) + setWorkerImport(response) + setMappings(buildSuggestedMappings(response.source_headers)) + setStep(3) + } catch (error) { + setRequestError(errorMessage(error)) + } finally { + setBusyAction(null) + } + } + + function handleMappingChange(sourceHeader: string, target: WorkerImportField | '') { + setMappings((current) => { + const next = { ...current } + delete next[sourceHeader] + if (!target) return next + + const duplicateSource = Object.entries(next).find(([, value]) => value === target)?.[0] + if (duplicateSource) delete next[duplicateSource] + next[sourceHeader] = target + return next + }) + } + + async function handleSaveMappings() { + if (!workerImport || !displayNameMapped) return + setBusyAction('mapping') + setRequestError(null) + try { + const mapped = await saveWorkerImportMappings(workerImport.import_id, { + expected_version: workerImport.version, + mappings, }) - }, 500) - return () => clearInterval(timer) - }, [step]) + const validated = await validateWorkerImport(mapped.import_id, mapped.version) + setWorkerImport(validated) + setMappings(validated.mappings) + setRowDrafts({}) + setExcludedRowNumbers([]) + setStep(4) + } catch (error) { + setRequestError(errorMessage(error)) + } finally { + setBusyAction(null) + } + } - function resolveCandidate(id: string) { - setCandidates((prev) => prev.map((c) => (c.id === id ? { ...c, status: 'ready' } : c))) + function updateRowDraft(rowNumber: number, field: string, value: string) { + setRowDrafts((current) => ({ + ...current, + [rowNumber]: { ...current[rowNumber], [field]: value }, + })) } - const readyCount = candidates.filter((c) => c.status === 'ready').length + function toggleExcluded(rowNumber: number) { + setExcludedRowNumbers((current) => + current.includes(rowNumber) + ? current.filter((number) => number !== rowNumber) + : [...current, rowNumber], + ) + } + + async function handleSaveRows() { + if (!workerImport || invalidRows.length === 0) return + setBusyAction('rows') + setRequestError(null) + try { + const patched = await patchWorkerImportRows(workerImport.import_id, { + expected_version: workerImport.version, + rows: invalidRows.map((row) => ({ + row_number: row.row_number, + excluded: excludedRowNumbers.includes(row.row_number), + values: Object.fromEntries( + row.errors.map((error) => [ + error.field, + rowDrafts[row.row_number]?.[error.field] ?? + getEffectiveRowValue(row, workerImport.mappings, error.field), + ]), + ), + })), + }) + const retried = await retryWorkerImport(patched.import_id, patched.version) + setWorkerImport(retried) + setRowDrafts({}) + setExcludedRowNumbers([]) + showToast('수정한 행을 다시 검증했습니다.') + } catch (error) { + setRequestError(errorMessage(error)) + } finally { + setBusyAction(null) + } + } + + async function handlePageChange(page: number) { + if (!workerImport) return + setBusyAction('page') + setRequestError(null) + try { + const response = await fetchWorkerImport(workerImport.import_id, { + page, + size: workerImport.size, + }) + setWorkerImport(response) + setRowDrafts({}) + setExcludedRowNumbers([]) + } catch (error) { + setRequestError(errorMessage(error)) + } finally { + setBusyAction(null) + } + } + + async function handleCommit() { + if (!workerImport || workerImport.valid_rows === 0 || workerImport.invalid_rows > 0) return + setBusyAction('commit') + setRequestError(null) + try { + commitKeyRef.current ??= crypto.randomUUID() + const committed = await commitWorkerImport( + workerImport.import_id, + { expected_version: workerImport.version, selected_row_numbers: [] }, + commitKeyRef.current, + ) + setWorkerImport(committed) + setStep(5) + } catch (error) { + setRequestError(errorMessage(error)) + } finally { + setBusyAction(null) + } + } return (
@@ -93,15 +235,22 @@ export function OnboardingImportPage() {
+ {requestError && ( +
+ 요청을 완료하지 못했습니다. +

{requestError}

+
+ )} + {step === 1 && (

어떻게 시작할까요?

@@ -133,9 +282,9 @@ export function OnboardingImportPage() { {step === 2 && (
-

근로자 파일을 올려주세요.

+

근로자 명단을 올려주세요.

- 근로자 표와 관련 문서를 분리해서 업로드합니다. Agent가 후보 컬럼을 추천합니다. + 최대 5MB의 CSV 또는 XLSX 파일을 올리면 원본 열과 근로자 정보를 연결합니다.

{SUPPORTED_FORMATS.map((format) => ( @@ -150,10 +299,17 @@ export function OnboardingImportPage() { type="file" className={styles.fileInput} onChange={(event) => handleFileChosen(event.target.files)} - accept=".xlsx,.csv,.pdf,.jpg,.jpeg,.png,.hwp,.hwpx,.docx" + accept=".xlsx,.csv" + aria-label="근로자 명단 파일" + disabled={busyAction !== null} /> - {fileName ? ( -

{fileName}

+ {selectedFile ? ( + <> +

{selectedFile.name}

+

+ {Math.max(1, Math.ceil(selectedFile.size / 1024))}KB +

+ ) : (

파일을 선택하거나 끌어다 놓으세요.

)} @@ -167,100 +323,268 @@ export function OnboardingImportPage() {
- -
)} - {step === 3 && ( + {step === 3 && workerImport && (
-

자료를 분석하고 있습니다.

-

- 업로드한 원본은 변경하지 않고 단계별 분석 상태만 보여드립니다. -

-
    - {ANALYSIS_STAGES.map((stage, index) => ( -
  • - - {stage} -
  • - ))} -
-
- +
+
+

파일의 열을 연결해 주세요.

+

+ 이름은 필수입니다. 사용하지 않을 열은 연결하지 않아도 됩니다. +

+
+ {workerImport.total_rows}행 확인
-
- )} - - {step === 4 && ( -
-

근로자·문서 후보를 검토합니다.

-

- {blockedCandidateCount > 0 - ? `확인이 필요한 항목 ${blockedCandidateCount}건을 해결해야 등록을 확정할 수 있습니다.` - : '모든 항목이 준비됐습니다. 등록을 확정하세요.'} -

-
- {candidates.map((candidate) => ( -
+
+ {workerImport.source_headers.map((header) => ( +
-

{candidate.name}

-

{candidate.detail}

+ 원본 열 + {header}
- - {REVIEW_STATUS_LABEL[candidate.status]} - - {candidate.status !== 'ready' && ( -
))}
+ {!displayNameMapped && ( +

‘이름’ 열을 연결해 주세요.

+ )}
- - +
+
+ )} + + {step === 4 && workerImport && ( +
+
+
+

검증 결과를 확인해 주세요.

+

+ 오류 행을 수정하거나 등록 대상에서 제외한 뒤 정상 행을 등록합니다. +

+
+ 0 ? 'warning' : 'success'}> + {IMPORT_STATUS_LABEL[workerImport.status]} + +
+ +
+
+

{workerImport.valid_rows}

+

정상

+
+
+

{workerImport.invalid_rows}

+

수정 필요

+
+
+

{workerImport.excluded_rows}

+

제외

+
+
+ +
+ {workerImport.rows.map((row) => { + const displayName = getEffectiveRowValue(row, workerImport.mappings, 'display_name') + const isLocallyExcluded = excludedRowNumbers.includes(row.row_number) + return ( +
+
+
+ {row.row_number}행 + {displayName || '이름 미입력'} +
+ + {row.status === 'VALID' + ? '정상' + : row.status === 'INVALID' + ? '수정 필요' + : row.status === 'EXCLUDED' + ? '제외' + : row.status === 'COMMITTED' + ? '등록 완료' + : '검증 대기'} + +
+ + {row.status === 'INVALID' && ( +
+ {row.errors.map((error) => ( + + ))} + +
+ )} +
+ ) + })} +
+ +
+ + {workerImport.page + 1}페이지 + +
+ + {workerImport.invalid_rows > invalidRows.length && ( +

+ 다른 페이지에도 수정이 필요한 행이 있습니다. 페이지를 이동해 확인해 주세요. +

+ )} + +
+ +
+ {invalidRows.length > 0 && ( + + )} + +
)} - {step === 5 && ( + {step === 5 && workerImport && (
-

{readyCount}명 등록을 완료했습니다.

-

등록 결과와 다음 진입점을 확인하세요.

+

{workerImport.committed_rows}명 등록을 완료했습니다.

+

+ 서버 등록 결과가 반영됐습니다. 이제 업무 요청에서 등록한 근로자를 선택할 수 있습니다. +

-

{readyCount}

+

{workerImport.committed_rows}

등록 완료

-

0

-

보류

+

{workerImport.excluded_rows}

+

제외

-

0

-

실패

+

{workerImport.invalid_rows}

+

미등록

@@ -274,9 +598,9 @@ export function OnboardingImportPage() {
diff --git a/src/pages/OnboardingImportPage/onboardingImportData.ts b/src/pages/OnboardingImportPage/onboardingImportData.ts index 0d3ef4b..850a729 100644 --- a/src/pages/OnboardingImportPage/onboardingImportData.ts +++ b/src/pages/OnboardingImportPage/onboardingImportData.ts @@ -1,3 +1,9 @@ +import type { + WorkerImportField, + WorkerImportRowResponse, + WorkerImportStatus, +} from '../../api/workerImports' + export type StartMode = 'migrate' | 'manual' | 'later' export interface StartOption { @@ -10,7 +16,7 @@ export const START_OPTIONS: StartOption[] = [ { id: 'migrate', title: '기존 데이터 이전', - description: '엑셀·문서 파일로 근로자 명단을 한 번에 가져옵니다.', + description: 'CSV·XLSX 파일로 근로자 명단을 한 번에 가져옵니다.', }, { id: 'manual', @@ -24,29 +30,78 @@ export const START_OPTIONS: StartOption[] = [ }, ] -export const SUPPORTED_FORMATS = ['XLSX', 'CSV', 'PDF', 'JPG', 'PNG', 'HWP', 'HWPX', 'DOCX'] +export const SUPPORTED_FORMATS = ['XLSX', 'CSV'] + +export const WORKER_IMPORT_FIELDS: { value: WorkerImportField; label: string }[] = [ + { value: 'display_name', label: '이름' }, + { value: 'nationality_code', label: '국적 코드' }, + { value: 'preferred_language', label: '선호 언어' }, + { value: 'visa_type', label: '체류 자격' }, + { value: 'stay_expiry_date', label: '체류 만료일' }, + { value: 'contract_start_date', label: '계약 시작일' }, + { value: 'contract_end_date', label: '계약 종료일' }, + { value: 'employment_permit_end_date', label: '고용허가 종료일' }, + { value: 'employment_activity_end_date', label: '취업활동기간 종료일' }, +] -export const ANALYSIS_STAGES = ['원본 확인', '보안 검사', '변환·파싱', '근로자 연결', '값 검증'] +export const IMPORT_STATUS_LABEL: Record = { + UPLOADED: '파일 확인 완료', + MAPPED: '열 연결 완료', + REVIEW_REQUIRED: '수정 필요', + READY: '등록 준비 완료', + COMMITTED: '등록 완료', +} -export type ReviewStatus = 'ready' | 'needs-info' | 'duplicate' | 'doc-type' +const HEADER_ALIASES: Record = { + display_name: ['이름', '성명', '근로자명', 'name', 'displayname'], + nationality_code: ['국적', '국적코드', 'nationality', 'nationalitycode'], + preferred_language: ['언어', '선호언어', 'preferredlanguage', 'language'], + visa_type: ['비자', '체류자격', '사증', 'visatype', 'visa'], + stay_expiry_date: ['체류만료일', '체류기간만료일', 'stayexpirydate'], + contract_start_date: ['계약시작일', '근로계약시작일', 'contractstartdate'], + contract_end_date: ['계약종료일', '근로계약종료일', 'contractenddate'], + employment_permit_end_date: ['고용허가종료일', 'employmentpermitenddate'], + employment_activity_end_date: ['취업활동기간종료일', 'employmentactivityenddate'], +} -export interface ReviewCandidate { - id: string - name: string - detail: string - status: ReviewStatus +function normalizeHeader(header: string) { + return header.toLowerCase().replace(/[\s_()\-./]/g, '') } -export const INITIAL_REVIEW_CANDIDATES: ReviewCandidate[] = [ - { id: 'c1', name: '응웬반A', detail: '베트남 · E-9 · 여권 M1234****', status: 'ready' }, - { id: 'c2', name: '쩐티B', detail: '체류만료일 값이 비어 있습니다.', status: 'needs-info' }, - { id: 'c3', name: '수라즈C', detail: '기존 근로자와 이름·여권번호가 유사합니다.', status: 'duplicate' }, - { id: 'c4', name: '아흐메드D', detail: '근로계약서 문서 유형을 확인해 주세요.', status: 'doc-type' }, -] +export function buildSuggestedMappings(headers: string[]): Record { + const result: Record = {} + const used = new Set() + + for (const header of headers) { + const normalized = normalizeHeader(header) + const match = WORKER_IMPORT_FIELDS.find( + ({ value }) => + !used.has(value) && + HEADER_ALIASES[value].some((alias) => normalizeHeader(alias) === normalized), + ) + if (match) { + result[header] = match.value + used.add(match.value) + } + } + + return result +} + +export function getWorkerImportFieldLabel(field: string) { + return WORKER_IMPORT_FIELDS.find((item) => item.value === field)?.label ?? field +} -export const REVIEW_STATUS_LABEL: Record = { - ready: '등록 준비 완료', - 'needs-info': '필수정보 확인', - duplicate: '중복 후보', - 'doc-type': '문서 유형 확인', +export function getEffectiveRowValue( + row: WorkerImportRowResponse, + mappings: Record, + field: string, +) { + const sourceHeader = Object.entries(mappings).find(([, target]) => target === field)?.[0] + return ( + row.override_values[field] ?? + row.normalized_values[field] ?? + (sourceHeader ? row.source_values[sourceHeader] : '') ?? + '' + ) }