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} + /> ) }