'use client'; /** * 사원 등록/수정/상세 폼 컴포넌트 * IntegratedDetailTemplate 마이그레이션 (2025-01-20) */ import { useState, useEffect, useCallback } from 'react'; import { useDaumPostcode } from '@/hooks/useDaumPostcode'; import { useRouter, useParams } from 'next/navigation'; import { toast } from 'sonner'; import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; import { employeeCreateConfig, employeeEditConfig, employeeConfig } from './employeeConfig'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { DatePicker } from '@/components/ui/date-picker'; import { Label } from '@/components/ui/label'; import { CurrencyInput } from '@/components/ui/currency-input'; import { PhoneInput } from '@/components/ui/phone-input'; import { PersonalNumberInput } from '@/components/ui/personal-number-input'; import { ImageUpload } from '@/components/ui/image-upload'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Plus, Trash2, Settings } from 'lucide-react'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { FieldSettingsDialog } from './FieldSettingsDialog'; import type { Employee, EmployeeFormData, DepartmentPosition, FieldSettings, } from './types'; import { EMPLOYMENT_TYPE_LABELS, USER_ROLE_LABELS, USER_ACCOUNT_STATUS_LABELS, EMPLOYEE_STATUS_LABELS, DEFAULT_FIELD_SETTINGS, } from './types'; import { getPositions, getDepartments, type PositionItem, type DepartmentItem } from './actions'; import { extractDigits } from '@/lib/formatters'; // 부서 트리 구조 타입 interface DepartmentTreeNode extends DepartmentItem { depth: number; children?: DepartmentTreeNode[]; } // 플랫 리스트를 트리 구조로 변환 function buildDepartmentTree(departments: DepartmentItem[]): DepartmentTreeNode[] { const map = new Map(); const roots: DepartmentTreeNode[] = []; // 먼저 모든 노드를 맵에 저장 departments.forEach(dept => { map.set(dept.id, { ...dept, depth: 0, children: [] }); }); // 부모-자식 관계 설정 departments.forEach(dept => { const node = map.get(dept.id)!; if (dept.parent_id && map.has(dept.parent_id)) { const parent = map.get(dept.parent_id)!; node.depth = parent.depth + 1; parent.children = parent.children || []; parent.children.push(node); } else { roots.push(node); } }); // 깊이 재계산 (재귀) function updateDepth(nodes: DepartmentTreeNode[], depth: number) { nodes.forEach(node => { node.depth = depth; if (node.children && node.children.length > 0) { updateDepth(node.children, depth + 1); } }); } updateDepth(roots, 0); return roots; } // 트리를 플랫 리스트로 변환 (depth 정보 유지) function flattenDepartmentTree(nodes: DepartmentTreeNode[]): DepartmentTreeNode[] { const result: DepartmentTreeNode[] = []; function traverse(nodeList: DepartmentTreeNode[]) { nodeList.forEach(node => { result.push(node); if (node.children && node.children.length > 0) { traverse(node.children); } }); } traverse(nodes); return result; } // 부서명 들여쓰기 포맷 function formatDepartmentName(name: string, depth: number): string { if (depth === 0) return name; const indent = '──'.repeat(depth); return `${indent} ${name}`; } interface EmployeeFormProps { mode: 'create' | 'edit' | 'view'; employee?: Employee;onSave?: (data: EmployeeFormData) => Promise<{ success: boolean; error?: string }>; onEdit?: () => void; onDelete?: () => void; fieldSettings?: FieldSettings; } // 유효성 검사 에러 타입 interface ValidationErrors { name?: string; email?: string; userId?: string; password?: string; confirmPassword?: string; } const initialFormData: EmployeeFormData = { name: '', residentNumber: '', phone: '', email: '', salary: '', bankAccount: { bankName: '', accountNumber: '', accountHolder: '' }, profileImage: '', employeeCode: '', gender: '', address: { zipCode: '', address1: '', address2: '' }, hireDate: '', employmentType: '', rank: '', status: 'active', departmentPositions: [], clockInLocation: '', clockOutLocation: '', resignationDate: '', resignationReason: '', hasUserAccount: false, userId: '', password: '', confirmPassword: '', role: 'user', accountStatus: 'active', }; export function EmployeeForm({ mode, employee, onSave, onEdit, onDelete, fieldSettings: initialFieldSettings = DEFAULT_FIELD_SETTINGS, }: EmployeeFormProps) { const router = useRouter(); const params = useParams(); const locale = params.locale as string || 'ko'; const [formData, setFormData] = useState(initialFormData); const [errors, setErrors] = useState({}); const isViewMode = mode === 'view'; // Daum 우편번호 서비스 const { openPostcode } = useDaumPostcode({ onComplete: (result) => { setFormData(prev => ({ ...prev, address: { ...prev.address, zipCode: result.zonecode, address1: result.address, }, })); }, }); // 항목 설정 상태 const [showFieldSettings, setShowFieldSettings] = useState(false); const [fieldSettings, setFieldSettings] = useState(initialFieldSettings); const _title = mode === 'create' ? '사원 등록' : mode === 'edit' ? '사원 수정' : '사원 상세'; const _description = mode === 'create' ? '새로운 사원 정보를 입력합니다' : mode === 'edit' ? '사원 정보를 수정합니다' : '사원 정보를 확인합니다'; // 직급/직책/부서 목록 const [ranks, setRanks] = useState([]); const [titles, setTitles] = useState([]); const [departments, setDepartments] = useState([]); // localStorage에서 항목 설정 로드 useEffect(() => { const saved = localStorage.getItem('employeeFieldSettings'); if (saved) { try { setFieldSettings(JSON.parse(saved)); } catch { // ignore parse error } } }, []); // 직급/직책/부서 목록 로드 useEffect(() => { const loadData = async () => { const [rankList, titleList, deptList] = await Promise.all([ getPositions('rank'), getPositions('title'), getDepartments(), ]); setRanks(rankList); setTitles(titleList); // 부서를 트리 구조로 변환 후 플랫 리스트로 (depth 정보 유지) const tree = buildDepartmentTree(deptList); const flatTree = flattenDepartmentTree(tree); setDepartments(flatTree); }; loadData(); }, []); // 항목 설정 저장 const handleSaveFieldSettings = (newSettings: FieldSettings) => { setFieldSettings(newSettings); localStorage.setItem('employeeFieldSettings', JSON.stringify(newSettings)); }; // 데이터 초기화 (edit, view 모드) useEffect(() => { if (employee && (mode === 'edit' || mode === 'view')) { setFormData({ name: employee.name, residentNumber: employee.residentNumber || '', phone: employee.phone || '', email: employee.email || '', salary: employee.salary?.toString() || '', bankAccount: employee.bankAccount || { bankName: '', accountNumber: '', accountHolder: '' }, profileImage: employee.profileImage || '', employeeCode: employee.employeeCode || '', gender: employee.gender || '', address: employee.address || { zipCode: '', address1: '', address2: '' }, hireDate: employee.hireDate || '', employmentType: employee.employmentType || '', rank: employee.rank || '', status: employee.status, departmentPositions: employee.departmentPositions || [], clockInLocation: employee.clockInLocation || '', clockOutLocation: employee.clockOutLocation || '', resignationDate: employee.resignationDate || '', resignationReason: employee.resignationReason || '', hasUserAccount: !!employee.userInfo, userId: employee.userInfo?.userId || '', password: '', confirmPassword: '', role: employee.userInfo?.role || 'user', accountStatus: employee.userInfo?.accountStatus || 'active', }); } }, [employee, mode]); // 휴대폰 번호 자동 하이픈 포맷팅 const formatPhoneNumber = (value: string): string => { const numbers = extractDigits(value); if (numbers.length <= 3) return numbers; if (numbers.length <= 7) return `${numbers.slice(0, 3)}-${numbers.slice(3)}`; return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7, 11)}`; }; // 주민등록번호 자동 하이픈 포맷팅 const formatResidentNumber = (value: string): string => { const numbers = extractDigits(value); if (numbers.length <= 6) return numbers; return `${numbers.slice(0, 6)}-${numbers.slice(6, 13)}`; }; // 입력 변경 핸들러 const handleChange = (field: keyof EmployeeFormData, value: unknown) => { let formattedValue = value; // 자동 하이픈 적용 if (field === 'phone' && typeof value === 'string') { formattedValue = formatPhoneNumber(value); } else if (field === 'residentNumber' && typeof value === 'string') { formattedValue = formatResidentNumber(value); } setFormData(prev => ({ ...prev, [field]: formattedValue })); // 에러 초기화 if (errors[field as keyof ValidationErrors]) { setErrors(prev => ({ ...prev, [field]: undefined })); } }; // 이메일 형식 검사 const isValidEmail = (email: string): boolean => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); }; // 유효성 검사 const validateForm = (): boolean => { const newErrors: ValidationErrors = {}; // 이름 필수 if (!formData.name.trim()) { newErrors.name = '이름을 입력해주세요.'; } // 이메일 필수 + 형식 검사 if (!formData.email.trim()) { newErrors.email = '이메일을 입력해주세요.'; } else if (!isValidEmail(formData.email)) { newErrors.email = '올바른 이메일 형식이 아닙니다.'; } // 아이디 필수 if (!formData.userId.trim()) { newErrors.userId = '아이디를 입력해주세요.'; } // 등록 모드일 때 비밀번호 검사 if (mode === 'create') { if (!formData.password) { newErrors.password = '비밀번호를 입력해주세요.'; } else if (formData.password.length < 8) { newErrors.password = '비밀번호는 8자 이상이어야 합니다.'; } if (formData.password !== formData.confirmPassword) { newErrors.confirmPassword = '비밀번호가 일치하지 않습니다.'; } } setErrors(newErrors); // 에러가 있으면 첫 번째 에러 메시지 표시 const firstError = Object.values(newErrors)[0]; if (firstError) { toast.error(firstError); return false; } return true; }; // 부서/직책 추가 const handleAddDepartmentPosition = () => { const newDP: DepartmentPosition = { id: String(Date.now()), departmentId: '', departmentName: '', positionId: '', positionName: '', }; setFormData(prev => ({ ...prev, departmentPositions: [...prev.departmentPositions, newDP], })); }; // 부서/직책 삭제 const handleRemoveDepartmentPosition = (id: string) => { setFormData(prev => ({ ...prev, departmentPositions: prev.departmentPositions.filter(dp => dp.id !== id), })); }; // 부서/직책 변경 const _handleDepartmentPositionChange = (id: string, field: keyof DepartmentPosition, value: string) => { setFormData(prev => ({ ...prev, departmentPositions: prev.departmentPositions.map(dp => dp.id === id ? { ...dp, [field]: value } : dp ), })); }; // 부서 선택 변경 (id와 name 모두 업데이트) const handleDepartmentSelect = (dpId: string, departmentId: string) => { const dept = departments.find(d => String(d.id) === departmentId); if (dept) { setFormData(prev => ({ ...prev, departmentPositions: prev.departmentPositions.map(dp => dp.id === dpId ? { ...dp, departmentId: String(dept.id), departmentName: dept.name } : dp ), })); } }; // 직책 선택 변경 (id와 name 모두 업데이트) const handlePositionSelect = (dpId: string, positionId: string) => { const position = titles.find(t => String(t.id) === positionId); if (position) { setFormData(prev => ({ ...prev, departmentPositions: prev.departmentPositions.map(dp => dp.id === dpId ? { ...dp, positionId: String(position.id), positionName: position.name } : dp ), })); } }; // 저장 (IntegratedDetailTemplate 호환) const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => { // view 모드에서는 저장 불가 if (isViewMode) { return { success: false, error: '보기 모드에서는 저장할 수 없습니다.' }; } // 유효성 검사 if (!validateForm()) { return { success: false, error: '입력 정보를 확인해주세요.' }; } // onSave 호출 (페이지에서 처리) if (onSave) { const result = await onSave(formData); if (result.success && mode === 'edit') { // 수정 모드: 저장 성공 시 view 모드로 전환 (리스트 이동 방지) toast.success('저장되었습니다.'); router.push(`/${locale}/hr/employee-management/${employee?.id}?mode=view`); return { success: false, error: '' }; // navigateToList 방지 + 에러 메시지 숨김 } return result; } return { success: false, error: '저장 핸들러가 설정되지 않았습니다.' }; }; // 취소 (목록으로 이동) const handleCancel = () => { router.push(`/${locale}/hr/employee-management`); }; // ===== 폼 콘텐츠 렌더링 ===== const renderFormContent = useCallback(() => ( <> {/* 항목 설정 버튼 */} {!isViewMode && (
)}
{/* 사원 정보 - 프로필 사진 + 기본 정보 */} 사원 정보 {/* 기본 정보 필드들 */}
handleChange('name', e.target.value)} placeholder="이름을 입력하세요" disabled={isViewMode} className={errors.name ? 'border-red-500' : ''} /> {errors.name &&

{errors.name}

}
handleChange('residentNumber', value)} placeholder="000000-0000000" disabled={isViewMode} />
handleChange('phone', value)} placeholder="010-0000-0000" disabled={isViewMode} />
handleChange('email', e.target.value)} placeholder="email@company.com" disabled={isViewMode} className={errors.email ? 'border-red-500' : ''} /> {errors.email &&

{errors.email}

}
handleChange('salary', value?.toString() ?? '')} placeholder="연봉" disabled={isViewMode} />
{/* 급여 계좌 */}
handleChange('bankAccount', { ...formData.bankAccount, bankName: e.target.value })} placeholder="은행명" disabled={isViewMode} /> handleChange('bankAccount', { ...formData.bankAccount, accountNumber: e.target.value })} placeholder="계좌번호" disabled={isViewMode} /> handleChange('bankAccount', { ...formData.bankAccount, accountHolder: e.target.value })} placeholder="예금주" disabled={isViewMode} />
{/* 사원 상세 */} {(fieldSettings.showProfileImage || fieldSettings.showEmployeeCode || fieldSettings.showGender || fieldSettings.showAddress) && ( 사원 상세 {/* 프로필 사진 + 사원코드/성별 */}
{/* 프로필 사진 영역 */} {fieldSettings.showProfileImage && (
{ // 미리보기 즉시 표시 const previewUrl = URL.createObjectURL(file); handleChange('profileImage', previewUrl); try { // 프록시를 통해 직접 업로드 (서버 액션 경유 시 FormData File 손실 방지) const uploadFormData = new FormData(); uploadFormData.append('file', file); uploadFormData.append('directory', 'employees/profiles'); const response = await fetch('/api/proxy/files/upload', { method: 'POST', body: uploadFormData, }); if (!response.ok) { throw new Error(`업로드 실패: ${response.status}`); } const result = await response.json(); if (result.success && result.data) { URL.revokeObjectURL(previewUrl); const fileId = result.data.id; const viewUrl = fileId ? `/api/proxy/files/${fileId}/view` : result.data.file_path || ''; handleChange('profileImage', viewUrl); } else { throw new Error(result.message || '업로드 실패'); } } catch (err) { URL.revokeObjectURL(previewUrl); handleChange('profileImage', ''); toast.error(err instanceof Error ? err.message : '이미지 업로드에 실패했습니다.'); } }} onRemove={() => handleChange('profileImage', '')} disabled={isViewMode} size="lg" rounded maxSize={10} hint={isViewMode ? undefined : '250 X 250px, 10MB 이하의 PNG, JPEG, GIF'} />
)} {/* 사원코드, 성별 */}
{fieldSettings.showEmployeeCode && (
handleChange('employeeCode', e.target.value)} placeholder="사원코드를 입력해주세요" disabled={isViewMode} />
)} {fieldSettings.showGender && (
handleChange('gender', value)} className="flex items-center gap-4 h-10" disabled={isViewMode} >
)}
{/* 주소 (사원코드/성별 아래) */} {fieldSettings.showAddress && (
{!isViewMode && ( )} handleChange('address', { ...formData.address, zipCode: e.target.value })} placeholder="" className="w-24" readOnly disabled={isViewMode} /> handleChange('address', { ...formData.address, address2: e.target.value })} placeholder="상세주소를 입력해주세요" className="flex-1" disabled={isViewMode} />
)}
)} {/* 인사 정보 */} {(fieldSettings.showHireDate || fieldSettings.showEmploymentType || fieldSettings.showRank || fieldSettings.showStatus || fieldSettings.showDepartment || fieldSettings.showPosition || fieldSettings.showClockInLocation || fieldSettings.showClockOutLocation || fieldSettings.showResignationDate || fieldSettings.showResignationReason) && ( 인사 정보
{fieldSettings.showHireDate && (
handleChange('hireDate', date)} disabled={isViewMode} />
)} {fieldSettings.showEmploymentType && (
)} {fieldSettings.showRank && (
)} {fieldSettings.showStatus && (
)}
{/* 부서/직책 */} {(fieldSettings.showDepartment || fieldSettings.showPosition) && (
{!isViewMode && ( )}
{formData.departmentPositions.length === 0 ? (

{isViewMode ? '등록된 부서/직책이 없습니다' : '부서/직책을 추가해주세요'}

) : (
{formData.departmentPositions.map((dp) => (
{!isViewMode && ( )}
))}
)}
)} {/* 출근/퇴근 위치 */}
{fieldSettings.showClockInLocation && (
)} {fieldSettings.showClockOutLocation && (
)}
{/* 퇴사일/퇴직사유 */} {(fieldSettings.showResignationDate || fieldSettings.showResignationReason) && (
{fieldSettings.showResignationDate && (
handleChange('resignationDate', date)} disabled={isViewMode} />
)} {fieldSettings.showResignationReason && (
handleChange('resignationReason', e.target.value)} placeholder="퇴직 사유를 입력하세요" disabled={isViewMode} />
)}
)}
)} {/* 사용자 정보 */} 사용자 정보
handleChange('userId', e.target.value)} placeholder="사용자 아이디" disabled={isViewMode} className={errors.userId ? 'border-red-500' : ''} /> {errors.userId &&

{errors.userId}

}
{mode === 'create' && ( <>
handleChange('password', e.target.value)} placeholder="비밀번호" className={errors.password ? 'border-red-500' : ''} /> {errors.password &&

{errors.password}

}
handleChange('confirmPassword', e.target.value)} placeholder="비밀번호 확인" className={errors.confirmPassword ? 'border-red-500' : ''} /> {errors.confirmPassword &&

{errors.confirmPassword}

}
)}
{/* 항목 설정 모달 */} ), [ formData, errors, isViewMode, mode, fieldSettings, showFieldSettings, ranks, titles, departments, handleChange, handleSaveFieldSettings, handleAddDepartmentPosition, handleRemoveDepartmentPosition, handleDepartmentSelect, handlePositionSelect, openPostcode, ]); // Config 선택 (create/edit/view) const getConfig = () => { if (mode === 'view') return employeeConfig; if (mode === 'edit') return employeeEditConfig; return employeeCreateConfig; }; // onDelete 타입 변환 (IntegratedDetailTemplate에서 id를 받지만, 외부에서는 void 타입) const handleDeleteWrapper = onDelete ? async () => { onDelete(); return { success: true }; } : undefined; return ( } stickyButtons={true} /> ); }