DatePicker 공통화: - date-picker.tsx 공통 컴포넌트 신규 추가 - 전체 폼 컴포넌트 DatePicker 통일 적용 (50+ 파일) - DateRangeSelector 개선 공정관리: - RuleModal 대폭 리팩토링 (-592줄 → 간소화) - ProcessForm, StepForm 개선 - ProcessDetail 수정, actions 확장 작업자화면: - WorkerScreen 기능 대폭 확장 (+543줄) - WorkItemCard 개선 - types 확장 회계/인사/영업/품질: - BadDebtDetail, BillDetail, DepositDetail, SalesDetail 등 DatePicker 적용 - EmployeeForm, VacationDialog 등 DatePicker 적용 - OrderRegistration, QuoteRegistration DatePicker 적용 - InspectionCreate, InspectionDetail DatePicker 적용 공사관리/CEO대시보드: - BiddingDetail, ContractDetail, HandoverReport 등 DatePicker 적용 - ScheduleDetailModal, TodayIssueSection 개선 기타: - WorkOrderCreate/Edit/Detail/List 개선 - ShipmentCreate/Edit, ReceivingDetail 개선 - calendar, calendarEvents 수정 - datepicker 마이그레이션 체크리스트 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
582 lines
21 KiB
TypeScript
582 lines
21 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
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 {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Plus, Trash2 } from 'lucide-react';
|
|
import type {
|
|
EmployeeDialogProps,
|
|
EmployeeFormData,
|
|
DepartmentPosition,
|
|
} from './types';
|
|
import {
|
|
EMPLOYMENT_TYPE_LABELS,
|
|
GENDER_LABELS,
|
|
USER_ROLE_LABELS,
|
|
USER_ACCOUNT_STATUS_LABELS,
|
|
EMPLOYEE_STATUS_LABELS,
|
|
} from './types';
|
|
|
|
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: [],
|
|
hasUserAccount: false,
|
|
userId: '',
|
|
password: '',
|
|
confirmPassword: '',
|
|
role: 'user',
|
|
accountStatus: 'active',
|
|
clockInLocation: '',
|
|
clockOutLocation: '',
|
|
resignationDate: '',
|
|
resignationReason: '',
|
|
};
|
|
|
|
export function EmployeeDialog({
|
|
open,
|
|
onOpenChange,
|
|
mode,
|
|
employee,
|
|
onSave,
|
|
fieldSettings,
|
|
}: EmployeeDialogProps) {
|
|
const [formData, setFormData] = useState<EmployeeFormData>(initialFormData);
|
|
|
|
// 모드별 타이틀
|
|
const title = {
|
|
create: '사원 등록',
|
|
edit: '사원 수정',
|
|
view: '사원 상세',
|
|
}[mode];
|
|
|
|
const isViewMode = mode === 'view';
|
|
|
|
// 데이터 초기화
|
|
useEffect(() => {
|
|
if (open && employee && mode !== 'create') {
|
|
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 || [],
|
|
hasUserAccount: !!employee.userInfo,
|
|
userId: employee.userInfo?.userId || '',
|
|
password: '',
|
|
confirmPassword: '',
|
|
role: employee.userInfo?.role || 'user',
|
|
accountStatus: employee.userInfo?.accountStatus || 'active',
|
|
clockInLocation: employee.clockInLocation || '',
|
|
clockOutLocation: employee.clockOutLocation || '',
|
|
resignationDate: employee.resignationDate || '',
|
|
resignationReason: employee.resignationReason || '',
|
|
});
|
|
} else if (open && mode === 'create') {
|
|
setFormData(initialFormData);
|
|
}
|
|
}, [open, employee, mode]);
|
|
|
|
// 입력 변경 핸들러
|
|
const handleChange = (field: keyof EmployeeFormData, value: unknown) => {
|
|
setFormData(prev => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
// 부서/직책 추가
|
|
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
|
|
),
|
|
}));
|
|
};
|
|
|
|
// 저장
|
|
const handleSubmit = () => {
|
|
onSave(formData);
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{title}</DialogTitle>
|
|
<DialogDescription>
|
|
{mode === 'create' ? '새로운 사원 정보를 입력합니다' : '사원 정보를 확인/수정합니다'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-6 py-4">
|
|
{/* 기본 사원정보 */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-semibold">기본 정보</h3>
|
|
|
|
<div className="grid 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)}
|
|
disabled={isViewMode}
|
|
placeholder="이름을 입력하세요"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="residentNumber">주민등록번호</Label>
|
|
<PersonalNumberInput
|
|
id="residentNumber"
|
|
value={formData.residentNumber}
|
|
onChange={(value) => handleChange('residentNumber', value)}
|
|
disabled={isViewMode}
|
|
placeholder="000000-0000000"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="phone">휴대폰</Label>
|
|
<PhoneInput
|
|
id="phone"
|
|
value={formData.phone}
|
|
onChange={(value) => handleChange('phone', value)}
|
|
disabled={isViewMode}
|
|
placeholder="010-0000-0000"
|
|
/>
|
|
</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)}
|
|
disabled={isViewMode}
|
|
placeholder="email@company.com"
|
|
/>
|
|
</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() ?? '')}
|
|
disabled={isViewMode}
|
|
placeholder="연봉"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 급여 계좌 */}
|
|
<div className="space-y-2">
|
|
<Label>급여계좌</Label>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<Input
|
|
value={formData.bankAccount.bankName}
|
|
onChange={(e) => handleChange('bankAccount', { ...formData.bankAccount, bankName: e.target.value })}
|
|
disabled={isViewMode}
|
|
placeholder="은행명"
|
|
/>
|
|
<Input
|
|
value={formData.bankAccount.accountNumber}
|
|
onChange={(e) => handleChange('bankAccount', { ...formData.bankAccount, accountNumber: e.target.value })}
|
|
disabled={isViewMode}
|
|
placeholder="계좌번호"
|
|
/>
|
|
<Input
|
|
value={formData.bankAccount.accountHolder}
|
|
onChange={(e) => handleChange('bankAccount', { ...formData.bankAccount, accountHolder: e.target.value })}
|
|
disabled={isViewMode}
|
|
placeholder="예금주"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* 선택적 필드 (설정에 따라 표시) */}
|
|
{(fieldSettings.showEmployeeCode || fieldSettings.showGender || fieldSettings.showAddress) && (
|
|
<>
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-semibold">추가 정보</h3>
|
|
|
|
<div className="grid 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)}
|
|
disabled={isViewMode}
|
|
placeholder="자동생성 또는 직접입력"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{fieldSettings.showGender && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="gender">성별</Label>
|
|
<Select
|
|
value={formData.gender}
|
|
onValueChange={(value) => handleChange('gender', value)}
|
|
disabled={isViewMode}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="성별 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(GENDER_LABELS).map(([value, label]) => (
|
|
<SelectItem key={value} value={value}>{label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{fieldSettings.showAddress && (
|
|
<div className="space-y-2">
|
|
<Label>주소</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={formData.address.zipCode}
|
|
onChange={(e) => handleChange('address', { ...formData.address, zipCode: e.target.value })}
|
|
disabled={isViewMode}
|
|
placeholder="우편번호"
|
|
className="w-32"
|
|
/>
|
|
<Button variant="outline" size="sm" disabled={isViewMode}>
|
|
우편번호 찾기
|
|
</Button>
|
|
</div>
|
|
<Input
|
|
value={formData.address.address1}
|
|
onChange={(e) => handleChange('address', { ...formData.address, address1: e.target.value })}
|
|
disabled={isViewMode}
|
|
placeholder="기본주소"
|
|
/>
|
|
<Input
|
|
value={formData.address.address2}
|
|
onChange={(e) => handleChange('address', { ...formData.address, address2: e.target.value })}
|
|
disabled={isViewMode}
|
|
placeholder="상세주소"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Separator />
|
|
</>
|
|
)}
|
|
|
|
{/* 인사 정보 */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-semibold">인사 정보</h3>
|
|
|
|
<div className="grid 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>
|
|
<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>
|
|
<Input
|
|
id="rank"
|
|
value={formData.rank}
|
|
onChange={(e) => handleChange('rank', e.target.value)}
|
|
disabled={isViewMode}
|
|
placeholder="직급 입력"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{fieldSettings.showStatus && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="status">상태</Label>
|
|
<Select
|
|
value={formData.status}
|
|
onValueChange={(value) => handleChange('status', value)}
|
|
disabled={isViewMode}
|
|
>
|
|
<SelectTrigger>
|
|
<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">부서/직책을 추가해주세요</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{formData.departmentPositions.map((dp) => (
|
|
<div key={dp.id} className="flex items-center gap-2">
|
|
<Input
|
|
value={dp.departmentName}
|
|
onChange={(e) => handleDepartmentPositionChange(dp.id, 'departmentName', e.target.value)}
|
|
disabled={isViewMode}
|
|
placeholder="부서명"
|
|
className="flex-1"
|
|
/>
|
|
<Input
|
|
value={dp.positionName}
|
|
onChange={(e) => handleDepartmentPositionChange(dp.id, 'positionName', e.target.value)}
|
|
disabled={isViewMode}
|
|
placeholder="직책"
|
|
className="flex-1"
|
|
/>
|
|
{!isViewMode && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleRemoveDepartmentPosition(dp.id)}
|
|
>
|
|
<Trash2 className="w-4 h-4 text-destructive" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* 사용자 정보 */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold">사용자 정보</h3>
|
|
{!isViewMode && (
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
id="hasUserAccount"
|
|
checked={formData.hasUserAccount}
|
|
onCheckedChange={(checked) => handleChange('hasUserAccount', checked)}
|
|
/>
|
|
<Label htmlFor="hasUserAccount" className="text-sm">
|
|
사용자 계정 생성
|
|
</Label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{(formData.hasUserAccount || (isViewMode && employee?.userInfo)) && (
|
|
<div className="grid 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)}
|
|
disabled={isViewMode}
|
|
placeholder="사용자 아이디"
|
|
/>
|
|
</div>
|
|
|
|
{!isViewMode && 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="비밀번호"
|
|
/>
|
|
</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="비밀번호 확인"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="role">권한</Label>
|
|
<Select
|
|
value={formData.role}
|
|
onValueChange={(value) => handleChange('role', value)}
|
|
disabled={isViewMode}
|
|
>
|
|
<SelectTrigger>
|
|
<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>
|
|
<SelectValue placeholder="상태 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(USER_ACCOUNT_STATUS_LABELS).map(([value, label]) => (
|
|
<SelectItem key={value} value={value}>{label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
{isViewMode ? '닫기' : '취소'}
|
|
</Button>
|
|
{!isViewMode && (
|
|
<Button onClick={handleSubmit}>
|
|
{mode === 'create' ? '등록' : '저장'}
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
} |