Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/api/workers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,35 @@ export function fetchWorkers(params: FetchWorkersParams = {}): Promise<WorkerPag
export function fetchWorkerById(workerId: string): Promise<WorkerResponse> {
return apiFetch<WorkerResponse>(`/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<WorkerResponse> {
return apiFetch<WorkerResponse>('/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<WorkerResponse> {
return apiFetch<WorkerResponse>(`/workers/${encodeURIComponent(workerId)}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
83 changes: 83 additions & 0 deletions src/components/worker/WorkerFormModal.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
199 changes: 199 additions & 0 deletions src/components/worker/WorkerFormModal.tsx
Original file line number Diff line number Diff line change
@@ -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<WorkStatus>('ACTIVE')
const [stayExpiryDate, setStayExpiryDate] = useState('')
const [contractStartDate, setContractStartDate] = useState('')
const [contractEndDate, setContractEndDate] = useState('')
const [submitting, setSubmitting] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
<Modal open={open} onClose={onClose} title={mode === 'create' ? '근로자 등록' : '근로자 정보 수정'}>
<p className={styles.fieldLabel}>표시 이름</p>
<input
type="text"
className={styles.textInput}
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder="응웬반A"
/>

<p className={`${styles.fieldLabel} ${styles.fieldLabelSpaced}`}>국적</p>
<div className={styles.chipRow}>
{NATIONALITY_OPTIONS.map((option) => (
<button
key={option.code}
type="button"
className={`${styles.chip} ${nationalityCode === option.code ? styles.chipSelected : ''}`}
onClick={() => handleSelectNationality(option)}
>
{option.label}
</button>
))}
</div>

<p className={`${styles.fieldLabel} ${styles.fieldLabelSpaced}`}>선호 언어</p>
<input
type="text"
className={styles.textInput}
value={preferredLanguage}
onChange={(event) => setPreferredLanguage(event.target.value)}
placeholder="vi"
/>

{mode === 'edit' && (
<>
<p className={`${styles.fieldLabel} ${styles.fieldLabelSpaced}`}>근무 상태</p>
<div className={styles.chipRow}>
{WORK_STATUS_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={`${styles.chip} ${workStatus === option.value ? styles.chipSelected : ''}`}
onClick={() => setWorkStatus(option.value)}
>
{option.label}
</button>
))}
</div>
</>
)}

<p className={`${styles.fieldLabel} ${styles.fieldLabelSpaced}`}>체류 만료일</p>
<input
type="date"
className={styles.textInput}
value={stayExpiryDate}
onChange={(event) => setStayExpiryDate(event.target.value)}
/>

<p className={`${styles.fieldLabel} ${styles.fieldLabelSpaced}`}>계약 기간</p>
<div className={styles.dateRow}>
<input
type="date"
className={styles.textInput}
aria-label="계약 시작일"
value={contractStartDate}
onChange={(event) => setContractStartDate(event.target.value)}
/>
<input
type="date"
className={styles.textInput}
aria-label="계약 종료일"
value={contractEndDate}
onChange={(event) => setContractEndDate(event.target.value)}
/>
</div>

{errorMessage && <p className={styles.errorText}>{errorMessage}</p>}

<div className={styles.actionRow}>
<button type="button" className={styles.textLink} onClick={onClose}>
취소
</button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? '저장 중...' : mode === 'create' ? '등록' : '저장'}
</Button>
</div>
</Modal>
)
}
2 changes: 1 addition & 1 deletion src/pages/WorkerDetailPage/WorkerDetailPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
margin-bottom: 0;
}

.registerDocumentButton {
.cardHeaderButton {
height: 32px;
padding: 0 14px;
background: none;
Expand Down
36 changes: 36 additions & 0 deletions src/pages/WorkerDetailPage/WorkerDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
})
Loading
Loading