- middleware CSP: *.kakao.com, *.kakaocdn.net 추가 (다음 주소찾기 차단 해결) - frame-src에 'self' 추가 - 공지 팝업 이미지 중앙정렬 ([&_img]:mx-auto) - HR 사원관리, 결재, 품목, 생산 등 다수 개선 - API 에러 핸들링 및 JSON 파싱 안정화
1068 lines
41 KiB
TypeScript
1068 lines
41 KiB
TypeScript
'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<number, DepartmentTreeNode>();
|
|
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<EmployeeFormData>(initialFormData);
|
|
const [errors, setErrors] = useState<ValidationErrors>({});
|
|
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<FieldSettings>(initialFieldSettings);
|
|
|
|
const _title = mode === 'create' ? '사원 등록' : mode === 'edit' ? '사원 수정' : '사원 상세';
|
|
const _description = mode === 'create'
|
|
? '새로운 사원 정보를 입력합니다'
|
|
: mode === 'edit'
|
|
? '사원 정보를 수정합니다'
|
|
: '사원 정보를 확인합니다';
|
|
// 직급/직책/부서 목록
|
|
const [ranks, setRanks] = useState<PositionItem[]>([]);
|
|
const [titles, setTitles] = useState<PositionItem[]>([]);
|
|
const [departments, setDepartments] = useState<DepartmentTreeNode[]>([]);
|
|
|
|
// 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 && (
|
|
<div className="flex justify-end mb-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setShowFieldSettings(true)}
|
|
>
|
|
<Settings className="w-4 h-4 mr-2" />
|
|
항목 설정
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-6">
|
|
{/* 사원 정보 - 프로필 사진 + 기본 정보 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">사원 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-6">
|
|
{/* 기본 정보 필드들 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">이름 *</Label>
|
|
<Input
|
|
id="name"
|
|
value={formData.name}
|
|
onChange={(e) => handleChange('name', e.target.value)}
|
|
placeholder="이름을 입력하세요"
|
|
disabled={isViewMode}
|
|
className={errors.name ? 'border-red-500' : ''}
|
|
/>
|
|
{errors.name && <p className="text-sm text-red-500">{errors.name}</p>}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="residentNumber">주민등록번호</Label>
|
|
<PersonalNumberInput
|
|
id="residentNumber"
|
|
value={formData.residentNumber}
|
|
onChange={(value) => handleChange('residentNumber', value)}
|
|
placeholder="000000-0000000"
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="phone">휴대폰</Label>
|
|
<PhoneInput
|
|
id="phone"
|
|
value={formData.phone}
|
|
onChange={(value) => handleChange('phone', value)}
|
|
placeholder="010-0000-0000"
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">이메일 *</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
value={formData.email}
|
|
onChange={(e) => handleChange('email', e.target.value)}
|
|
placeholder="email@company.com"
|
|
disabled={isViewMode}
|
|
className={errors.email ? 'border-red-500' : ''}
|
|
/>
|
|
{errors.email && <p className="text-sm text-red-500">{errors.email}</p>}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="salary">연봉</Label>
|
|
<CurrencyInput
|
|
id="salary"
|
|
value={formData.salary ? Number(formData.salary) : undefined}
|
|
onChange={(value) => handleChange('salary', value?.toString() ?? '')}
|
|
placeholder="연봉"
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 급여 계좌 */}
|
|
<div className="space-y-2 mt-6">
|
|
<Label>급여계좌</Label>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
|
<Input
|
|
value={formData.bankAccount.bankName}
|
|
onChange={(e) => handleChange('bankAccount', { ...formData.bankAccount, bankName: e.target.value })}
|
|
placeholder="은행명"
|
|
disabled={isViewMode}
|
|
/>
|
|
<Input
|
|
value={formData.bankAccount.accountNumber}
|
|
onChange={(e) => handleChange('bankAccount', { ...formData.bankAccount, accountNumber: e.target.value })}
|
|
placeholder="계좌번호"
|
|
disabled={isViewMode}
|
|
/>
|
|
<Input
|
|
value={formData.bankAccount.accountHolder}
|
|
onChange={(e) => handleChange('bankAccount', { ...formData.bankAccount, accountHolder: e.target.value })}
|
|
placeholder="예금주"
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 사원 상세 */}
|
|
{(fieldSettings.showProfileImage || fieldSettings.showEmployeeCode || fieldSettings.showGender || fieldSettings.showAddress) && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">사원 상세</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-6 space-y-4">
|
|
{/* 프로필 사진 + 사원코드/성별 */}
|
|
<div className="flex flex-col md:flex-row gap-6">
|
|
{/* 프로필 사진 영역 */}
|
|
{fieldSettings.showProfileImage && (
|
|
<div className="space-y-2 flex-shrink-0">
|
|
<Label>프로필 사진</Label>
|
|
<ImageUpload
|
|
value={formData.profileImage}
|
|
onChange={async (file) => {
|
|
// 미리보기 즉시 표시
|
|
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'}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 사원코드, 성별 */}
|
|
<div className="flex-1 space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{fieldSettings.showEmployeeCode && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="employeeCode">사원코드</Label>
|
|
<Input
|
|
id="employeeCode"
|
|
value={formData.employeeCode}
|
|
onChange={(e) => handleChange('employeeCode', e.target.value)}
|
|
placeholder="사원코드를 입력해주세요"
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{fieldSettings.showGender && (
|
|
<div className="space-y-2">
|
|
<Label>성별</Label>
|
|
<RadioGroup
|
|
value={formData.gender}
|
|
onValueChange={(value) => handleChange('gender', value)}
|
|
className="flex items-center gap-4 h-10"
|
|
disabled={isViewMode}
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="male" id="gender-male" disabled={isViewMode} />
|
|
<Label htmlFor="gender-male" className="font-normal cursor-pointer">남성</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="female" id="gender-female" disabled={isViewMode} />
|
|
<Label htmlFor="gender-female" className="font-normal cursor-pointer">여성</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 주소 (사원코드/성별 아래) */}
|
|
{fieldSettings.showAddress && (
|
|
<div className="space-y-2">
|
|
<Label>주소</Label>
|
|
<div className="flex gap-2">
|
|
{!isViewMode && (
|
|
<Button type="button" variant="default" size="sm" onClick={openPostcode} className="bg-blue-500 hover:bg-blue-600">
|
|
우편번호 찾기
|
|
</Button>
|
|
)}
|
|
<Input
|
|
value={formData.address.zipCode}
|
|
onChange={(e) => handleChange('address', { ...formData.address, zipCode: e.target.value })}
|
|
placeholder=""
|
|
className="w-24"
|
|
readOnly
|
|
disabled={isViewMode}
|
|
/>
|
|
<Input
|
|
value={formData.address.address2}
|
|
onChange={(e) => handleChange('address', { ...formData.address, address2: e.target.value })}
|
|
placeholder="상세주소를 입력해주세요"
|
|
className="flex-1"
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 인사 정보 */}
|
|
{(fieldSettings.showHireDate || fieldSettings.showEmploymentType || fieldSettings.showRank || fieldSettings.showStatus || fieldSettings.showDepartment || fieldSettings.showPosition || fieldSettings.showClockInLocation || fieldSettings.showClockOutLocation || fieldSettings.showResignationDate || fieldSettings.showResignationReason) && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">인사 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-6 space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{fieldSettings.showHireDate && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="hireDate">입사일</Label>
|
|
<DatePicker
|
|
value={formData.hireDate}
|
|
onChange={(date) => handleChange('hireDate', date)}
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{fieldSettings.showEmploymentType && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="employmentType">고용형태</Label>
|
|
<Select
|
|
value={formData.employmentType}
|
|
onValueChange={(value) => handleChange('employmentType', value)}
|
|
disabled={isViewMode}
|
|
>
|
|
<SelectTrigger disabled={isViewMode}>
|
|
<SelectValue placeholder="고용형태 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(EMPLOYMENT_TYPE_LABELS).map(([value, label]) => (
|
|
<SelectItem key={value} value={value}>{label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{fieldSettings.showRank && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="rank">직급</Label>
|
|
<Select
|
|
value={formData.rank}
|
|
onValueChange={(value) => handleChange('rank', value)}
|
|
disabled={isViewMode}
|
|
>
|
|
<SelectTrigger disabled={isViewMode}>
|
|
<SelectValue placeholder="직급 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{ranks.map((rank) => (
|
|
<SelectItem key={rank.id} value={rank.name}>{rank.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{fieldSettings.showStatus && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="status">상태</Label>
|
|
<Select
|
|
value={formData.status}
|
|
onValueChange={(value) => handleChange('status', value)}
|
|
disabled={isViewMode}
|
|
>
|
|
<SelectTrigger disabled={isViewMode}>
|
|
<SelectValue placeholder="상태 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(EMPLOYEE_STATUS_LABELS).map(([value, label]) => (
|
|
<SelectItem key={value} value={value}>{label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 부서/직책 */}
|
|
{(fieldSettings.showDepartment || fieldSettings.showPosition) && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label>부서/직책</Label>
|
|
{!isViewMode && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleAddDepartmentPosition}
|
|
>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
추가
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{formData.departmentPositions.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-4 text-center border rounded-md">
|
|
{isViewMode ? '등록된 부서/직책이 없습니다' : '부서/직책을 추가해주세요'}
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{formData.departmentPositions.map((dp) => (
|
|
<div key={dp.id} className="flex items-center gap-2">
|
|
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
<Select
|
|
value={dp.departmentId}
|
|
onValueChange={(value) => handleDepartmentSelect(dp.id, value)}
|
|
disabled={isViewMode}
|
|
>
|
|
<SelectTrigger disabled={isViewMode}>
|
|
<SelectValue placeholder="부서 선택">
|
|
{dp.departmentName || '부서 선택'}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{departments.map((dept) => (
|
|
<SelectItem key={dept.id} value={String(dept.id)}>
|
|
<span style={{ fontFamily: 'monospace' }}>
|
|
{formatDepartmentName(dept.name, dept.depth)}
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select
|
|
value={dp.positionId}
|
|
onValueChange={(value) => handlePositionSelect(dp.id, value)}
|
|
disabled={isViewMode}
|
|
>
|
|
<SelectTrigger disabled={isViewMode}>
|
|
<SelectValue placeholder="직책 선택">
|
|
{dp.positionName || '직책 선택'}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{titles.map((title) => (
|
|
<SelectItem key={title.id} value={String(title.id)}>{title.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{!isViewMode && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="shrink-0"
|
|
onClick={() => handleRemoveDepartmentPosition(dp.id)}
|
|
>
|
|
<Trash2 className="w-4 h-4 text-destructive" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 출근/퇴근 위치 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{fieldSettings.showClockInLocation && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="clockInLocation">출근 위치</Label>
|
|
<Select
|
|
value={formData.clockInLocation}
|
|
onValueChange={(value) => handleChange('clockInLocation', value)}
|
|
disabled={isViewMode}
|
|
>
|
|
<SelectTrigger disabled={isViewMode}>
|
|
<SelectValue placeholder="출근 위치 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="headquarters">본사</SelectItem>
|
|
<SelectItem value="branch1">지사1</SelectItem>
|
|
<SelectItem value="branch2">지사2</SelectItem>
|
|
<SelectItem value="remote">재택</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{fieldSettings.showClockOutLocation && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="clockOutLocation">퇴근 위치</Label>
|
|
<Select
|
|
value={formData.clockOutLocation}
|
|
onValueChange={(value) => handleChange('clockOutLocation', value)}
|
|
disabled={isViewMode}
|
|
>
|
|
<SelectTrigger disabled={isViewMode}>
|
|
<SelectValue placeholder="퇴근 위치 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="headquarters">본사</SelectItem>
|
|
<SelectItem value="branch1">지사1</SelectItem>
|
|
<SelectItem value="branch2">지사2</SelectItem>
|
|
<SelectItem value="remote">재택</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 퇴사일/퇴직사유 */}
|
|
{(fieldSettings.showResignationDate || fieldSettings.showResignationReason) && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{fieldSettings.showResignationDate && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="resignationDate">퇴사일</Label>
|
|
<DatePicker
|
|
value={formData.resignationDate}
|
|
onChange={(date) => handleChange('resignationDate', date)}
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{fieldSettings.showResignationReason && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="resignationReason">퇴직사유</Label>
|
|
<Input
|
|
id="resignationReason"
|
|
value={formData.resignationReason}
|
|
onChange={(e) => handleChange('resignationReason', e.target.value)}
|
|
placeholder="퇴직 사유를 입력하세요"
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 사용자 정보 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">사용자 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="userId">아이디 *</Label>
|
|
<Input
|
|
id="userId"
|
|
value={formData.userId}
|
|
onChange={(e) => handleChange('userId', e.target.value)}
|
|
placeholder="사용자 아이디"
|
|
disabled={isViewMode}
|
|
className={errors.userId ? 'border-red-500' : ''}
|
|
/>
|
|
{errors.userId && <p className="text-sm text-red-500">{errors.userId}</p>}
|
|
</div>
|
|
|
|
{mode === 'create' && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password">비밀번호 *</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
value={formData.password}
|
|
onChange={(e) => handleChange('password', e.target.value)}
|
|
placeholder="비밀번호"
|
|
className={errors.password ? 'border-red-500' : ''}
|
|
/>
|
|
{errors.password && <p className="text-sm text-red-500">{errors.password}</p>}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="confirmPassword">비밀번호 확인 *</Label>
|
|
<Input
|
|
id="confirmPassword"
|
|
type="password"
|
|
value={formData.confirmPassword}
|
|
onChange={(e) => handleChange('confirmPassword', e.target.value)}
|
|
placeholder="비밀번호 확인"
|
|
className={errors.confirmPassword ? 'border-red-500' : ''}
|
|
/>
|
|
{errors.confirmPassword && <p className="text-sm text-red-500">{errors.confirmPassword}</p>}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="role">권한</Label>
|
|
<Select
|
|
value={formData.role}
|
|
onValueChange={(value) => handleChange('role', value)}
|
|
disabled={isViewMode}
|
|
>
|
|
<SelectTrigger disabled={isViewMode}>
|
|
<SelectValue placeholder="권한 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(USER_ROLE_LABELS).map(([value, label]) => (
|
|
<SelectItem key={value} value={value}>{label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="accountStatus">계정상태</Label>
|
|
<Select
|
|
value={formData.accountStatus}
|
|
onValueChange={(value) => handleChange('accountStatus', value)}
|
|
disabled={isViewMode}
|
|
>
|
|
<SelectTrigger disabled={isViewMode}>
|
|
<SelectValue placeholder="상태 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(USER_ACCOUNT_STATUS_LABELS).map(([value, label]) => (
|
|
<SelectItem key={value} value={value}>{label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
</div>
|
|
|
|
{/* 항목 설정 모달 */}
|
|
<FieldSettingsDialog
|
|
open={showFieldSettings}
|
|
onOpenChange={setShowFieldSettings}
|
|
settings={fieldSettings}
|
|
onSave={handleSaveFieldSettings}
|
|
/>
|
|
</>
|
|
), [
|
|
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 (
|
|
<IntegratedDetailTemplate
|
|
config={getConfig()}
|
|
mode={mode}
|
|
isLoading={false}
|
|
onCancel={handleCancel}
|
|
onSubmit={handleSubmit}
|
|
onEdit={onEdit}
|
|
onDelete={handleDeleteWrapper}
|
|
renderForm={renderFormContent}
|
|
renderView={renderFormContent}
|
|
initialData={employee as unknown as Record<string, unknown>}
|
|
stickyButtons={true}
|
|
/>
|
|
);
|
|
} |