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() {
- {Array.from({ length: STEP_COUNT }, (_, index) => index + 1).map((s) => (
+ {Array.from({ length: STEP_COUNT }, (_, index) => index + 1).map((item) => (
))}
+ {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) => (
- -
-
- {index < analysisDone ? '✓' : index + 1}
-
- {stage}
-
- ))}
-
-
-
finishOnboarding('/dashboard')}>
- 분석 취소하고 홈으로
-
+
+
+
파일의 열을 연결해 주세요.
+
+ 이름은 필수입니다. 사용하지 않을 열은 연결하지 않아도 됩니다.
+
+
+
{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' && (
-
resolveCandidate(candidate.id)}
+
+ →
+
+
- )}
+
+ {WORKER_IMPORT_FIELDS.map((field) => (
+
+ ))}
+
+
))}
+ {!displayNameMapped && (
+
‘이름’ 열을 연결해 주세요.
+ )}
- setStep(2)}>
+ setStep(2)}
+ >
이전
- 0} onClick={() => setStep(5)}>
- 등록 확정 →
+
+ 저장하고 행 검증 →
+
+
+
+ )}
+
+ {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) => (
+
+ ))}
+
+
+ )}
+
+ )
+ })}
+
+
+
+ handlePageChange(workerImport.page - 1)}
+ >
+ 이전 행
+
+ {workerImport.page + 1}페이지
+ handlePageChange(workerImport.page + 1)}
+ >
+ 다음 행
+
+
+
+ {workerImport.invalid_rows > invalidRows.length && (
+
+ 다른 페이지에도 수정이 필요한 행이 있습니다. 페이지를 이동해 확인해 주세요.
+
+ )}
+
+
+
setStep(3)}
+ >
+ 열 연결 수정
+
+ {invalidRows.length > 0 && (
+
+ 수정·제외 저장 후 재검증
+
+ )}
+ 0 ||
+ workerImport.valid_rows === 0
+ }
+ isLoading={busyAction === 'commit'}
+ onClick={handleCommit}
+ >
+ 정상 {workerImport.valid_rows}명 등록 →
+
+
)}
- {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() {
finishOnboarding('/documents')}
+ onClick={() => finishOnboarding('/tasks/new')}
>
- 문서함 보기 →
+ 새 업무 요청 →
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] : '') ??
+ ''
+ )
}