From 102ce5e373a8e3503574d507587e94b6309d0945 Mon Sep 17 00:00:00 2001
From: mini
Date: Thu, 6 Aug 2026 15:12:28 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B7=BC=EB=A1=9C=EC=9E=90=20=EB=93=B1?=
=?UTF-8?q?=EB=A1=9D/=EC=88=98=EC=A0=95=EC=9D=84=20workers=20API=EC=97=90?=
=?UTF-8?q?=20=EC=97=B0=EA=B2=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WorkerController에 이미 있던 POST/PATCH /workers를 아무데도 안 부르고 있어서 UI에서 근로자를 등록·수정할 방법이 없었음. WorkerFormModal 하나로 등록/수정 겸용 폼 만들고, 목록 페이지엔 등록 버튼, 상세 페이지 기본정보 카드엔 수정 버튼 붙임.
Closes #270
---
src/api/workers.ts | 32 +++
.../worker/WorkerFormModal.module.css | 83 ++++++++
src/components/worker/WorkerFormModal.tsx | 199 ++++++++++++++++++
.../WorkerDetailPage.module.css | 2 +-
.../WorkerDetailPage.test.tsx | 36 ++++
.../WorkerDetailPage/WorkerDetailPage.tsx | 19 +-
.../WorkerListPage/WorkerListPage.test.tsx | 29 +++
src/pages/WorkerListPage/WorkerListPage.tsx | 15 ++
8 files changed, 412 insertions(+), 3 deletions(-)
create mode 100644 src/components/worker/WorkerFormModal.module.css
create mode 100644 src/components/worker/WorkerFormModal.tsx
diff --git a/src/api/workers.ts b/src/api/workers.ts
index ea55c51..bc31aeb 100644
--- a/src/api/workers.ts
+++ b/src/api/workers.ts
@@ -48,3 +48,35 @@ export function fetchWorkers(params: FetchWorkersParams = {}): Promise {
return apiFetch(`/workers/${encodeURIComponent(workerId)}`)
}
+
+// 여권번호·외국인등록번호·전화번호·계좌번호는 WorkerController가 이 API로 수집하지 않는다.
+export interface WorkerCreateBody {
+ display_name: string
+ nationality_code?: string
+ preferred_language?: string
+ stay_expiry_date?: string
+ contract_start_date?: string
+ contract_end_date?: string
+}
+
+export function registerWorker(body: WorkerCreateBody): Promise {
+ return apiFetch('/workers', { method: 'POST', body: JSON.stringify(body) })
+}
+
+export interface WorkerPatchBody {
+ display_name?: string
+ nationality_code?: string
+ preferred_language?: string
+ work_status?: WorkStatus
+ stay_expiry_date?: string
+ contract_start_date?: string
+ contract_end_date?: string
+ expected_version: number
+}
+
+export function patchWorker(workerId: string, body: WorkerPatchBody): Promise {
+ return apiFetch(`/workers/${encodeURIComponent(workerId)}`, {
+ method: 'PATCH',
+ body: JSON.stringify(body),
+ })
+}
diff --git a/src/components/worker/WorkerFormModal.module.css b/src/components/worker/WorkerFormModal.module.css
new file mode 100644
index 0000000..1e1e574
--- /dev/null
+++ b/src/components/worker/WorkerFormModal.module.css
@@ -0,0 +1,83 @@
+.fieldLabel {
+ margin: 0 0 8px;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.fieldLabelSpaced {
+ margin-top: var(--fowoco-spacing-20);
+}
+
+.textInput {
+ width: 100%;
+ box-sizing: border-box;
+ height: 48px;
+ padding: 0 15px;
+ background: var(--surface-default);
+ border: 1px solid var(--border-default);
+ border-radius: var(--fowoco-radius-6);
+ font-size: 13px;
+ font-weight: 500;
+ font-family: inherit;
+ color: var(--text-primary);
+}
+
+.textInput:focus-visible {
+ outline: 2px solid var(--brand-primary);
+ outline-offset: 2px;
+}
+
+.dateRow {
+ display: flex;
+ gap: 12px;
+}
+
+.dateRow .textInput {
+ flex: 1;
+}
+
+.chipRow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.chip {
+ height: 38px;
+ padding: 0 16px;
+ background: var(--surface-subtle);
+ border: none;
+ border-radius: var(--fowoco-radius-999);
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+
+.chipSelected {
+ background: var(--fowoco-teal-50);
+ color: var(--brand-primary);
+}
+
+.errorText {
+ margin: 8px 0 0;
+ font-size: 12px;
+ color: var(--status-critical);
+}
+
+.actionRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: var(--fowoco-spacing-24);
+}
+
+.textLink {
+ background: none;
+ border: none;
+ padding: 0;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ cursor: pointer;
+}
diff --git a/src/components/worker/WorkerFormModal.tsx b/src/components/worker/WorkerFormModal.tsx
new file mode 100644
index 0000000..182aad2
--- /dev/null
+++ b/src/components/worker/WorkerFormModal.tsx
@@ -0,0 +1,199 @@
+import { useEffect, useState } from 'react'
+import { registerWorker, patchWorker, type WorkerResponse, type WorkStatus } from '../../api/workers'
+import { ApiError, getErrorMessage } from '../../api/errors'
+import { Button } from '../ui/Button/Button'
+import { Modal } from '../ui/Modal/Modal'
+import styles from './WorkerFormModal.module.css'
+
+const NATIONALITY_OPTIONS: { code: string; label: string; language: string }[] = [
+ { code: 'VN', label: '베트남', language: 'vi' },
+ { code: 'ID', label: '인도네시아', language: 'id' },
+ { code: 'KH', label: '캄보디아', language: 'km' },
+ { code: 'NP', label: '네팔', language: 'ne' },
+ { code: 'MM', label: '미얀마', language: 'my' },
+ { code: 'PH', label: '필리핀', language: 'tl' },
+ { code: 'TH', label: '태국', language: 'th' },
+]
+
+const WORK_STATUS_OPTIONS: { value: WorkStatus; label: string }[] = [
+ { value: 'ACTIVE', label: '재직' },
+ { value: 'ON_LEAVE', label: '휴직' },
+ { value: 'RESIGNED', label: '퇴사' },
+ { value: 'TERMINATED', label: '계약종료' },
+]
+
+export interface WorkerFormModalProps {
+ open: boolean
+ mode: 'create' | 'edit'
+ worker?: WorkerResponse
+ onClose: () => void
+ onSaved: (worker: WorkerResponse) => void
+}
+
+function errorMessageOf(error: unknown, fallback: string): string {
+ if (error instanceof ApiError) {
+ if (error.fieldErrors.length > 0) return error.fieldErrors.map((item) => item.message).join(' ')
+ return getErrorMessage(error)
+ }
+ return fallback
+}
+
+export function WorkerFormModal({ open, mode, worker, onClose, onSaved }: WorkerFormModalProps) {
+ const [displayName, setDisplayName] = useState('')
+ const [nationalityCode, setNationalityCode] = useState('')
+ const [preferredLanguage, setPreferredLanguage] = useState('')
+ const [workStatus, setWorkStatus] = useState('ACTIVE')
+ const [stayExpiryDate, setStayExpiryDate] = useState('')
+ const [contractStartDate, setContractStartDate] = useState('')
+ const [contractEndDate, setContractEndDate] = useState('')
+ const [submitting, setSubmitting] = useState(false)
+ const [errorMessage, setErrorMessage] = useState(null)
+
+ useEffect(() => {
+ if (!open) return
+ setDisplayName(worker?.display_name ?? '')
+ setNationalityCode(worker?.nationality_code ?? '')
+ setPreferredLanguage(worker?.preferred_language ?? '')
+ setWorkStatus(worker?.work_status ?? 'ACTIVE')
+ setStayExpiryDate(worker?.stay_expiry_date ?? '')
+ setContractStartDate(worker?.contract_start_date ?? '')
+ setContractEndDate(worker?.contract_end_date ?? '')
+ setErrorMessage(null)
+ }, [open, worker])
+
+ function handleSelectNationality(option: (typeof NATIONALITY_OPTIONS)[number]) {
+ setNationalityCode(option.code)
+ if (!preferredLanguage) setPreferredLanguage(option.language)
+ }
+
+ async function handleSubmit() {
+ if (!displayName.trim()) {
+ setErrorMessage('표시 이름을 입력해 주세요.')
+ return
+ }
+
+ setSubmitting(true)
+ setErrorMessage(null)
+ try {
+ const saved =
+ mode === 'create'
+ ? await registerWorker({
+ display_name: displayName.trim(),
+ nationality_code: nationalityCode || undefined,
+ preferred_language: preferredLanguage || undefined,
+ stay_expiry_date: stayExpiryDate || undefined,
+ contract_start_date: contractStartDate || undefined,
+ contract_end_date: contractEndDate || undefined,
+ })
+ : await patchWorker(worker!.worker_id, {
+ display_name: displayName.trim(),
+ nationality_code: nationalityCode || undefined,
+ preferred_language: preferredLanguage || undefined,
+ work_status: workStatus,
+ stay_expiry_date: stayExpiryDate || undefined,
+ contract_start_date: contractStartDate || undefined,
+ contract_end_date: contractEndDate || undefined,
+ expected_version: worker!.version,
+ })
+ onSaved(saved)
+ onClose()
+ } catch (error) {
+ setErrorMessage(
+ errorMessageOf(error, mode === 'create' ? '근로자를 등록하지 못했습니다.' : '근로자 정보를 수정하지 못했습니다.'),
+ )
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+ 표시 이름
+ setDisplayName(event.target.value)}
+ placeholder="응웬반A"
+ />
+
+ 국적
+
+ {NATIONALITY_OPTIONS.map((option) => (
+
+ ))}
+
+
+ 선호 언어
+ setPreferredLanguage(event.target.value)}
+ placeholder="vi"
+ />
+
+ {mode === 'edit' && (
+ <>
+ 근무 상태
+
+ {WORK_STATUS_OPTIONS.map((option) => (
+
+ ))}
+
+ >
+ )}
+
+ 체류 만료일
+ setStayExpiryDate(event.target.value)}
+ />
+
+ 계약 기간
+
+ setContractStartDate(event.target.value)}
+ />
+ setContractEndDate(event.target.value)}
+ />
+
+
+ {errorMessage && {errorMessage}
}
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.module.css b/src/pages/WorkerDetailPage/WorkerDetailPage.module.css
index a1c3e25..8139620 100644
--- a/src/pages/WorkerDetailPage/WorkerDetailPage.module.css
+++ b/src/pages/WorkerDetailPage/WorkerDetailPage.module.css
@@ -77,7 +77,7 @@
margin-bottom: 0;
}
-.registerDocumentButton {
+.cardHeaderButton {
height: 32px;
padding: 0 14px;
background: none;
diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx b/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx
index 37d5965..3e22cde 100644
--- a/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx
+++ b/src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx
@@ -215,4 +215,40 @@ describe('WorkerDetailPage', () => {
expect(calls.some((c) => c.url.includes('/workers/W-018/documents') && c.method === 'POST')).toBe(true)
expect(calls.some((c) => c.url.includes('/documents/D-2') && c.method === 'PATCH')).toBe(true)
})
+
+ it('edits worker info and refreshes the detail panel', async () => {
+ const user = userEvent.setup()
+ const calls: { url: string; method: string; body: string | undefined }[] = []
+ let workerGetCount = 0
+ vi.mocked(fetch).mockImplementation((input, init) => {
+ const url = String(input)
+ const method = init?.method ?? 'GET'
+ calls.push({ url, method, body: init?.body as string | undefined })
+ if (url.includes('/documents')) {
+ return Promise.resolve(jsonResponse({ items: [], page: 0, size: 100, total_elements: 0 }))
+ }
+ if (url.includes('/workers/W-018') && method === 'PATCH') {
+ return Promise.resolve(jsonResponse(worker({ display_name: '쩐티B(수정)', version: 2 })))
+ }
+ workerGetCount += 1
+ return Promise.resolve(
+ jsonResponse(worker(workerGetCount === 1 ? {} : { display_name: '쩐티B(수정)', version: 2 })),
+ )
+ })
+ renderPage('W-018')
+ await screen.findByRole('heading', { name: '쩐티B' })
+
+ await user.click(screen.getByRole('button', { name: '정보 수정' }))
+ expect(screen.getByRole('dialog', { name: '근로자 정보 수정' })).toBeInTheDocument()
+
+ const nameInput = screen.getByDisplayValue('쩐티B')
+ await user.clear(nameInput)
+ await user.type(nameInput, '쩐티B(수정)')
+ await user.click(screen.getByRole('button', { name: '저장' }))
+
+ expect(await screen.findByRole('heading', { name: '쩐티B(수정)' })).toBeInTheDocument()
+ const patchCall = calls.find((c) => c.url.includes('/workers/W-018') && c.method === 'PATCH')
+ expect(patchCall).toBeDefined()
+ expect(JSON.parse(patchCall!.body!)).toMatchObject({ display_name: '쩐티B(수정)', expected_version: 1 })
+ })
})
diff --git a/src/pages/WorkerDetailPage/WorkerDetailPage.tsx b/src/pages/WorkerDetailPage/WorkerDetailPage.tsx
index b949e6c..0513695 100644
--- a/src/pages/WorkerDetailPage/WorkerDetailPage.tsx
+++ b/src/pages/WorkerDetailPage/WorkerDetailPage.tsx
@@ -6,6 +6,7 @@ import { getErrorMessage } from '../../api/errors'
import { DetailRow } from '../../components/ui/DetailRow/DetailRow'
import { EmptyState } from '../../components/ui/EmptyState/EmptyState'
import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel'
+import { WorkerFormModal } from '../../components/worker/WorkerFormModal'
import { useApiQuery } from '../../hooks/useApiQuery'
import { getDocumentViewModel } from '../../view-models/documentViewModel'
import { getOperationalDateViewModel } from '../../view-models/dateViewModel'
@@ -28,6 +29,7 @@ export function WorkerDetailPage() {
const workerDocuments = documentPage?.items ?? []
const [registerModalOpen, setRegisterModalOpen] = useState(false)
+ const [editModalOpen, setEditModalOpen] = useState(false)
if (status === 'loading') {
return (
@@ -79,7 +81,12 @@ export function WorkerDetailPage() {
-
기본정보
+
+
기본정보
+
+
{/* TODO(#48): worker_sensitive_data API 연동 후 사번·연락처 표시 */}
@@ -99,7 +106,7 @@ export function WorkerDetailPage() {
서류
)
}
diff --git a/src/pages/WorkerListPage/WorkerListPage.test.tsx b/src/pages/WorkerListPage/WorkerListPage.test.tsx
index 1ea95a3..dfd50b5 100644
--- a/src/pages/WorkerListPage/WorkerListPage.test.tsx
+++ b/src/pages/WorkerListPage/WorkerListPage.test.tsx
@@ -278,4 +278,33 @@ describe('WorkerListPage', () => {
expect(screen.getByText('표시할 근로자가 없습니다')).toBeInTheDocument()
})
+
+ it('registers a new worker and switches the detail panel to it', async () => {
+ const user = userEvent.setup()
+ const newWorker = worker({ worker_id: 'W-099', display_name: '신규근로자' })
+ let workersGetCount = 0
+ vi.mocked(fetch).mockImplementation((input, init) => {
+ const url = String(input)
+ const method = init?.method ?? 'GET'
+ if (url.includes('/activities')) return Promise.resolve(jsonResponse([]))
+ if (url.includes('/tasks')) {
+ return Promise.resolve(jsonResponse({ items: [], page: 0, size: 100, total_elements: 0, total_pages: 0 }))
+ }
+ if (url.includes('/workers') && method === 'POST') {
+ return Promise.resolve(jsonResponse(newWorker, { status: 201 }))
+ }
+ workersGetCount += 1
+ const items = workersGetCount === 1 ? WORKERS : [...WORKERS, newWorker]
+ return Promise.resolve(jsonResponse(pageResponse(items)))
+ })
+ renderPage()
+
+ await user.click(await screen.findByRole('button', { name: '+ 근로자 등록' }))
+ expect(screen.getByRole('dialog', { name: '근로자 등록' })).toBeInTheDocument()
+ await user.type(screen.getByPlaceholderText('응웬반A'), '신규근로자')
+ await user.click(screen.getByRole('button', { name: '등록' }))
+
+ expect(await screen.findByRole('heading', { name: '신규근로자' })).toBeInTheDocument()
+ expect(screen.queryByRole('dialog', { name: '근로자 등록' })).not.toBeInTheDocument()
+ })
})
diff --git a/src/pages/WorkerListPage/WorkerListPage.tsx b/src/pages/WorkerListPage/WorkerListPage.tsx
index 168dad0..cb0fb20 100644
--- a/src/pages/WorkerListPage/WorkerListPage.tsx
+++ b/src/pages/WorkerListPage/WorkerListPage.tsx
@@ -5,6 +5,7 @@ import { getErrorMessage } from '../../api/errors'
import { fetchTasks, type TaskSummaryResponse } from '../../api/tasks'
import { fetchWorkers, type WorkerResponse } from '../../api/workers'
import { Button } from '../../components/ui/Button/Button'
+import { WorkerFormModal } from '../../components/worker/WorkerFormModal'
import { DetailRow } from '../../components/ui/DetailRow/DetailRow'
import { Dropdown } from '../../components/ui/Dropdown/Dropdown'
import { EmptyState } from '../../components/ui/EmptyState/EmptyState'
@@ -86,6 +87,7 @@ export function WorkerListPage() {
const [deadlineFilter, setDeadlineFilter] = useState('90')
const [statusFilter, setStatusFilter] = useState('all')
const [showAll, setShowAll] = useState(false)
+ const [registerModalOpen, setRegisterModalOpen] = useState(false)
const debouncedQuery = useDebouncedValue(query)
// 서버 GET /api/v1/workers에는 자유 텍스트 검색 파라미터가 없어, 한 페이지(최대 100건)를
@@ -161,6 +163,11 @@ export function WorkerListPage() {
navigate(`/workers/${selectedRow.worker.worker_id}/detail`)
}
+ function handleWorkerRegistered(worker: WorkerResponse) {
+ refetch()
+ navigate({ pathname: `/workers/${worker.worker_id}`, search: location.search })
+ }
+
return (
체류·서류 확인이 필요한 근로자 {visibleRows.length}명
@@ -182,6 +189,7 @@ export function WorkerListPage() {
onChange={setDeadlineFilter}
ariaLabel="기한 필터"
/>
+
개인정보 마스킹 켜짐
@@ -367,6 +375,13 @@ export function WorkerListPage() {
)}
+
+ setRegisterModalOpen(false)}
+ onSaved={handleWorkerRegistered}
+ />
)
}