feat: enhanced employee edit modal (profile, equipment, dispatch, status)
This commit is contained in:
@@ -10,7 +10,11 @@ import com.srsystem.dto.UpdateAchievementsRequest;
|
|||||||
import com.srsystem.entity.DispatchHistory;
|
import com.srsystem.entity.DispatchHistory;
|
||||||
import com.srsystem.entity.Employee;
|
import com.srsystem.entity.Employee;
|
||||||
import com.srsystem.entity.EmployeeEmergencyContact;
|
import com.srsystem.entity.EmployeeEmergencyContact;
|
||||||
|
import com.srsystem.entity.EquipmentAssignment;
|
||||||
|
import com.srsystem.entity.enums.DispatchStatus;
|
||||||
import com.srsystem.entity.enums.EmpStatus;
|
import com.srsystem.entity.enums.EmpStatus;
|
||||||
|
import com.srsystem.entity.enums.RoleCode;
|
||||||
|
import com.srsystem.repository.EquipmentAssignmentRepository;
|
||||||
import com.srsystem.service.DispatchHistoryService;
|
import com.srsystem.service.DispatchHistoryService;
|
||||||
import com.srsystem.service.EmployeeService;
|
import com.srsystem.service.EmployeeService;
|
||||||
import com.srsystem.service.PortfolioService;
|
import com.srsystem.service.PortfolioService;
|
||||||
@@ -42,6 +46,7 @@ public class EmployeeController {
|
|||||||
private final EmployeeService employeeService;
|
private final EmployeeService employeeService;
|
||||||
private final DispatchHistoryService dispatchHistoryService;
|
private final DispatchHistoryService dispatchHistoryService;
|
||||||
private final PortfolioService portfolioService;
|
private final PortfolioService portfolioService;
|
||||||
|
private final EquipmentAssignmentRepository equipmentAssignmentRepository;
|
||||||
|
|
||||||
@Operation(summary = "직원 목록 조회", description = "페이징된 직원 목록을 조회합니다. 상태 필터 옵션 포함")
|
@Operation(summary = "직원 목록 조회", description = "페이징된 직원 목록을 조회합니다. 상태 필터 옵션 포함")
|
||||||
@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
|
@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
|
||||||
@@ -86,7 +91,7 @@ public class EmployeeController {
|
|||||||
return ResponseEntity.ok(ApiResponse.ok("직원이 등록되었습니다", created));
|
return ResponseEntity.ok(ApiResponse.ok("직원이 등록되었습니다", created));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "직원 정보 수정", description = "직원 정보를 수정합니다 (퇴사자는 수정 불가)")
|
@Operation(summary = "직원 정보 수정", description = "기본 정보(이름/이메일/전화번호/생년월일/학력/전공/졸업일/거주지/프로필사진) 수정")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@PutMapping("/{empId}")
|
@PutMapping("/{empId}")
|
||||||
public ResponseEntity<ApiResponse<Employee>> update(
|
public ResponseEntity<ApiResponse<Employee>> update(
|
||||||
@@ -96,6 +101,37 @@ public class EmployeeController {
|
|||||||
return ResponseEntity.ok(ApiResponse.ok("직원 정보가 수정되었습니다", updated));
|
return ResponseEntity.ok(ApiResponse.ok("직원 정보가 수정되었습니다", updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "직원 재직 상태 변경", description = "ACTIVE/ON_LEAVE/RESIGNED + 퇴사일")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@PutMapping("/{empId}/status")
|
||||||
|
public ResponseEntity<ApiResponse<Employee>> updateStatus(
|
||||||
|
@PathVariable Long empId, @RequestBody Map<String, String> body) {
|
||||||
|
EmpStatus status = EmpStatus.valueOf(body.get("empStatus"));
|
||||||
|
String leftDate = body.get("leftDate");
|
||||||
|
Employee updated = employeeService.updateStatus(empId, status, leftDate);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok("상태가 변경되었습니다", updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "직원 권한 변경", description = "ADMIN/ MANAGER/ EMPLOYEE")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@PutMapping("/{empId}/role")
|
||||||
|
public ResponseEntity<ApiResponse<Employee>> updateRole(
|
||||||
|
@PathVariable Long empId, @RequestBody Map<String, String> body) {
|
||||||
|
RoleCode role = RoleCode.valueOf(body.get("roleCode"));
|
||||||
|
Employee updated = employeeService.updateRole(empId, role);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok("권한이 변경되었습니다", updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "직원 파견 상태 변경", description = "WAITING/ DISPATCHED")
|
||||||
|
@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
|
||||||
|
@PutMapping("/{empId}/dispatch-status")
|
||||||
|
public ResponseEntity<ApiResponse<Employee>> updateDispatchStatus(
|
||||||
|
@PathVariable Long empId, @RequestBody Map<String, String> body) {
|
||||||
|
DispatchStatus status = DispatchStatus.valueOf(body.get("dispatchStatus"));
|
||||||
|
Employee updated = employeeService.updateDispatchStatus(empId, status);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok("파견 상태가 변경되었습니다", updated));
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "직원 삭제", description = "직원을 삭제합니다 (Soft Delete, 퇴사자는 삭제 불가)")
|
@Operation(summary = "직원 삭제", description = "직원을 삭제합니다 (Soft Delete, 퇴사자는 삭제 불가)")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@DeleteMapping("/{empId}")
|
@DeleteMapping("/{empId}")
|
||||||
@@ -154,8 +190,16 @@ public class EmployeeController {
|
|||||||
return ResponseEntity.ok(ApiResponse.ok("성과가 저장되었습니다", null));
|
return ResponseEntity.ok(ApiResponse.ok("성과가 저장되었습니다", null));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Emergency Contacts ----
|
@Operation(summary = "직원 지급 장비 조회", description = "직원에게 지급된 장비 목록을 조회합니다 (반납되지 않은)")
|
||||||
|
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
@GetMapping("/{empId}/equipment")
|
||||||
|
public ResponseEntity<ApiResponse<List<EquipmentAssignment>>> getEquipment(@PathVariable Long empId) {
|
||||||
|
List<EquipmentAssignment> assignments = equipmentAssignmentRepository.findByUser_EmpIdAndReturnedDateIsNull(empId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(assignments));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Emergency Contacts ----
|
||||||
@Operation(summary = "비상연락망 목록 조회", description = "직원의 비상연락망 목록을 조회합니다")
|
@Operation(summary = "비상연락망 목록 조회", description = "직원의 비상연락망 목록을 조회합니다")
|
||||||
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
|
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
|
||||||
@GetMapping("/{empId}/emergency-contacts")
|
@GetMapping("/{empId}/emergency-contacts")
|
||||||
|
|||||||
@@ -11,4 +11,6 @@ import java.util.Optional;
|
|||||||
public interface EquipmentAssignmentRepository extends JpaRepository<EquipmentAssignment, Long> {
|
public interface EquipmentAssignmentRepository extends JpaRepository<EquipmentAssignment, Long> {
|
||||||
List<EquipmentAssignment> findByEquipment_EquipmentId(Long equipmentId);
|
List<EquipmentAssignment> findByEquipment_EquipmentId(Long equipmentId);
|
||||||
Optional<EquipmentAssignment> findByEquipment_EquipmentIdAndReturnedDateIsNull(Long equipmentId);
|
Optional<EquipmentAssignment> findByEquipment_EquipmentIdAndReturnedDateIsNull(Long equipmentId);
|
||||||
|
List<EquipmentAssignment> findByUser_EmpIdAndReturnedDateIsNull(Long empId);
|
||||||
|
List<EquipmentAssignment> findByUser_EmpIdOrderByAssignedDateDesc(Long empId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import com.srsystem.dto.EmployeeSearchResult;
|
|||||||
import com.srsystem.dto.EmployeeSummary;
|
import com.srsystem.dto.EmployeeSummary;
|
||||||
import com.srsystem.entity.Employee;
|
import com.srsystem.entity.Employee;
|
||||||
import com.srsystem.entity.EmployeeEmergencyContact;
|
import com.srsystem.entity.EmployeeEmergencyContact;
|
||||||
|
import com.srsystem.entity.enums.DispatchStatus;
|
||||||
import com.srsystem.entity.enums.EmpStatus;
|
import com.srsystem.entity.enums.EmpStatus;
|
||||||
|
import com.srsystem.entity.enums.RoleCode;
|
||||||
import com.srsystem.exception.BusinessException;
|
import com.srsystem.exception.BusinessException;
|
||||||
import com.srsystem.exception.ErrorCode;
|
import com.srsystem.exception.ErrorCode;
|
||||||
import com.srsystem.repository.DispatchHistoryRepository;
|
import com.srsystem.repository.DispatchHistoryRepository;
|
||||||
@@ -18,6 +20,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -99,11 +102,7 @@ public class EmployeeService {
|
|||||||
employee.setName(updateRequest.getName());
|
employee.setName(updateRequest.getName());
|
||||||
employee.setEmail(updateRequest.getEmail());
|
employee.setEmail(updateRequest.getEmail());
|
||||||
employee.setPhone(updateRequest.getPhone());
|
employee.setPhone(updateRequest.getPhone());
|
||||||
employee.setRoleCode(updateRequest.getRoleCode());
|
|
||||||
employee.setEmpStatus(updateRequest.getEmpStatus());
|
|
||||||
employee.setDispatchStatus(updateRequest.getDispatchStatus());
|
|
||||||
employee.setJoinedDate(updateRequest.getJoinedDate());
|
employee.setJoinedDate(updateRequest.getJoinedDate());
|
||||||
employee.setLeftDate(updateRequest.getLeftDate());
|
|
||||||
employee.setBirthDate(updateRequest.getBirthDate());
|
employee.setBirthDate(updateRequest.getBirthDate());
|
||||||
employee.setEducation(updateRequest.getEducation());
|
employee.setEducation(updateRequest.getEducation());
|
||||||
employee.setMajor(updateRequest.getMajor());
|
employee.setMajor(updateRequest.getMajor());
|
||||||
@@ -111,13 +110,36 @@ public class EmployeeService {
|
|||||||
employee.setResidence(updateRequest.getResidence());
|
employee.setResidence(updateRequest.getResidence());
|
||||||
employee.setProfileImage(updateRequest.getProfileImage());
|
employee.setProfileImage(updateRequest.getProfileImage());
|
||||||
|
|
||||||
if (updateRequest.getPassword() != null && !updateRequest.getPassword().isBlank()) {
|
|
||||||
employee.setPassword(passwordEncoder.encode(updateRequest.getPassword()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return employee;
|
return employee;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Employee updateStatus(Long empId, EmpStatus status, String leftDate) {
|
||||||
|
Employee employee = findById(empId);
|
||||||
|
employee.setEmpStatus(status);
|
||||||
|
if (leftDate != null && !leftDate.isBlank()) {
|
||||||
|
employee.setLeftDate(LocalDate.parse(leftDate));
|
||||||
|
}
|
||||||
|
if (status == EmpStatus.RESIGNED && leftDate != null) {
|
||||||
|
employee.setLeftDate(LocalDate.parse(leftDate));
|
||||||
|
}
|
||||||
|
return employeeRepository.save(employee);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Employee updateRole(Long empId, RoleCode role) {
|
||||||
|
Employee employee = findById(empId);
|
||||||
|
employee.setRoleCode(role);
|
||||||
|
return employeeRepository.save(employee);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Employee updateDispatchStatus(Long empId, DispatchStatus status) {
|
||||||
|
Employee employee = findById(empId);
|
||||||
|
employee.setDispatchStatus(status);
|
||||||
|
return employeeRepository.save(employee);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void delete(Long empId) {
|
public void delete(Long empId) {
|
||||||
Employee employee = findById(empId);
|
Employee employee = findById(empId);
|
||||||
|
|||||||
+780
-142
@@ -1,12 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Search, Plus, MoreHorizontal, X, Pencil, Trash2, AlertTriangle } from 'lucide-react';
|
import { Search, Plus, MoreHorizontal, X, Pencil, Trash2, AlertTriangle, Upload, Camera, Link as LinkIcon, Clock, MapPin, Briefcase } from 'lucide-react';
|
||||||
import employeeService from '@/services/employeeService';
|
import employeeService from '@/services/employeeService';
|
||||||
|
import apiClient from '@/services/apiClient';
|
||||||
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||||
import { useAuthStore } from '@/stores';
|
import { useAuthStore } from '@/stores';
|
||||||
import type { Employee, RoleCode, EmpStatus, DispatchStatus } from '@/types';
|
import type { Employee, RoleCode, EmpStatus, DispatchStatus, DispatchHistory, Project, EquipmentAssignment } from '@/types';
|
||||||
|
|
||||||
const statusColor: Record<string, string> = {
|
const statusColor: Record<string, string> = {
|
||||||
ACTIVE: 'bg-status-resolved-bg text-status-resolved-text',
|
ACTIVE: 'bg-status-resolved-bg text-status-resolved-text',
|
||||||
@@ -50,14 +51,42 @@ export default function EmployeesPage() {
|
|||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const [editModalError, setEditModalError] = useState<string | null>(null);
|
const [editModalError, setEditModalError] = useState<string | null>(null);
|
||||||
const [editData, setEditData] = useState({
|
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
|
||||||
|
|
||||||
|
// Edit Modal - Section data
|
||||||
|
const [editBasic, setEditBasic] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
roleCode: 'EMPLOYEE' as RoleCode,
|
phone: '',
|
||||||
empStatus: 'ACTIVE' as EmpStatus,
|
joinedDate: '',
|
||||||
dispatchStatus: 'WAITING' as DispatchStatus,
|
birthDate: '',
|
||||||
|
education: '',
|
||||||
|
major: '',
|
||||||
|
graduationDate: '',
|
||||||
|
residence: '',
|
||||||
|
profileImage: '',
|
||||||
});
|
});
|
||||||
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
|
const [editStatus, setEditStatus] = useState({
|
||||||
|
empStatus: 'ACTIVE' as EmpStatus,
|
||||||
|
leftDate: '',
|
||||||
|
});
|
||||||
|
const [editRole, setEditRole] = useState<RoleCode>('EMPLOYEE');
|
||||||
|
const [editDispatchStatus, setEditDispatchStatus] = useState<DispatchStatus>('WAITING');
|
||||||
|
|
||||||
|
// Edit Modal - Fetched data
|
||||||
|
const [equipmentList, setEquipmentList] = useState<EquipmentAssignment[]>([]);
|
||||||
|
const [dispatchHistories, setDispatchHistories] = useState<DispatchHistory[]>([]);
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [editLoading, setEditLoading] = useState(false);
|
||||||
|
|
||||||
|
// Profile image upload
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Confirmation dialogs within edit modal
|
||||||
|
const [showRoleConfirm, setShowRoleConfirm] = useState(false);
|
||||||
|
const [pendingRole, setPendingRole] = useState<RoleCode | null>(null);
|
||||||
|
const [showStatusConfirm, setShowStatusConfirm] = useState(false);
|
||||||
|
const [pendingStatus, setPendingStatus] = useState<EmpStatus | null>(null);
|
||||||
|
|
||||||
// Delete Confirmation State
|
// Delete Confirmation State
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
@@ -145,57 +174,267 @@ export default function EmployeesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Edit handlers
|
// Edit handlers
|
||||||
const handleEdit = (emp: Employee) => {
|
const handleEdit = async (emp: Employee) => {
|
||||||
setEditingEmployee(emp);
|
setEditingEmployee(emp);
|
||||||
setEditData({
|
setEditModalError(null);
|
||||||
|
setEditLoading(true);
|
||||||
|
|
||||||
|
// Populate basic info
|
||||||
|
setEditBasic({
|
||||||
name: emp.name,
|
name: emp.name,
|
||||||
email: emp.email || '',
|
email: emp.email || '',
|
||||||
roleCode: emp.roleCode,
|
phone: emp.phone || '',
|
||||||
empStatus: emp.empStatus,
|
joinedDate: emp.joinedDate || '',
|
||||||
dispatchStatus: emp.dispatchStatus,
|
birthDate: emp.birthDate || '',
|
||||||
|
education: emp.education || '',
|
||||||
|
major: emp.major || '',
|
||||||
|
graduationDate: emp.graduationDate || '',
|
||||||
|
residence: emp.residence || '',
|
||||||
|
profileImage: emp.profileImage || '',
|
||||||
});
|
});
|
||||||
setEditModalError(null);
|
setEditStatus({
|
||||||
|
empStatus: emp.empStatus,
|
||||||
|
leftDate: emp.leftDate || '',
|
||||||
|
});
|
||||||
|
setEditRole(emp.roleCode);
|
||||||
|
setEditDispatchStatus(emp.dispatchStatus);
|
||||||
|
|
||||||
|
// Fetch related data in parallel
|
||||||
|
try {
|
||||||
|
const [equipRes, dispatchRes, projectsRes] = await Promise.allSettled([
|
||||||
|
apiClient.get<any>(`/employees/${emp.empId}/equipment`),
|
||||||
|
employeeService.getDispatchHistories(emp.empId),
|
||||||
|
apiClient.get<any>('/projects'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (equipRes.status === 'fulfilled' && equipRes.value.success) {
|
||||||
|
setEquipmentList(equipRes.value.data || []);
|
||||||
|
} else {
|
||||||
|
setEquipmentList([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispatchRes.status === 'fulfilled' && dispatchRes.value.success) {
|
||||||
|
setDispatchHistories(dispatchRes.value.data || []);
|
||||||
|
} else {
|
||||||
|
setDispatchHistories([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectsRes.status === 'fulfilled' && projectsRes.value.success) {
|
||||||
|
setProjects(projectsRes.value.data || []);
|
||||||
|
} else {
|
||||||
|
setProjects([]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load edit modal data:', err);
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
setIsEditModalOpen(true);
|
setIsEditModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseEditModal = () => {
|
const handleCloseEditModal = () => {
|
||||||
setIsEditModalOpen(false);
|
setIsEditModalOpen(false);
|
||||||
setEditingEmployee(null);
|
setEditingEmployee(null);
|
||||||
setEditData({
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
roleCode: 'EMPLOYEE',
|
|
||||||
empStatus: 'ACTIVE',
|
|
||||||
dispatchStatus: 'WAITING',
|
|
||||||
});
|
|
||||||
setEditModalError(null);
|
setEditModalError(null);
|
||||||
|
setEquipmentList([]);
|
||||||
|
setDispatchHistories([]);
|
||||||
|
setProjects([]);
|
||||||
|
setShowRoleConfirm(false);
|
||||||
|
setShowStatusConfirm(false);
|
||||||
|
setPendingRole(null);
|
||||||
|
setPendingStatus(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
const handleBasicInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setEditData((prev) => ({ ...prev, [name]: value }));
|
setEditBasic((prev) => ({ ...prev, [name]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateSubmit = async (e: React.FormEvent) => {
|
const handleProfileImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
e.preventDefault();
|
const file = e.target.files?.[0];
|
||||||
|
if (!file || !editingEmployee) return;
|
||||||
|
|
||||||
if (!editData.name.trim()) {
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const uploadRes = await fetch('/api/v1/files/upload', { method: 'POST', body: form });
|
||||||
|
const uploadData = await uploadRes.json();
|
||||||
|
if (uploadData.success && uploadData.data) {
|
||||||
|
await employeeService.updateProfileImage(editingEmployee.empId, uploadData.data);
|
||||||
|
setEditBasic((prev) => ({ ...prev, profileImage: uploadData.data }));
|
||||||
|
// Update local employee list
|
||||||
|
setEmployees((prev) =>
|
||||||
|
prev.map((emp) =>
|
||||||
|
emp.empId === editingEmployee.empId ? { ...emp, profileImage: uploadData.data } : emp
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setEditModalError(err.message || 'Failed to upload profile image.');
|
||||||
|
}
|
||||||
|
// Reset input
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveProfileImage = async () => {
|
||||||
|
if (!editingEmployee) return;
|
||||||
|
try {
|
||||||
|
await employeeService.updateProfileImage(editingEmployee.empId, '');
|
||||||
|
setEditBasic((prev) => ({ ...prev, profileImage: '' }));
|
||||||
|
setEmployees((prev) =>
|
||||||
|
prev.map((emp) =>
|
||||||
|
emp.empId === editingEmployee.empId ? { ...emp, profileImage: null } : emp
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
setEditModalError(err.message || 'Failed to remove profile image.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveBasicInfo = async () => {
|
||||||
|
if (!editingEmployee) return;
|
||||||
|
if (!editBasic.name.trim()) {
|
||||||
setEditModalError('Name is required.');
|
setEditModalError('Name is required.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!editingEmployee) return;
|
|
||||||
|
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
setEditModalError(null);
|
setEditModalError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await employeeService.update(editingEmployee.empId, editData);
|
const updateData: Record<string, unknown> = {
|
||||||
alert('Employee updated successfully!');
|
name: editBasic.name,
|
||||||
handleCloseEditModal();
|
email: editBasic.email,
|
||||||
await fetchEmployees();
|
phone: editBasic.phone,
|
||||||
|
joinedDate: editBasic.joinedDate,
|
||||||
|
birthDate: editBasic.birthDate,
|
||||||
|
education: editBasic.education,
|
||||||
|
major: editBasic.major,
|
||||||
|
graduationDate: editBasic.graduationDate,
|
||||||
|
residence: editBasic.residence,
|
||||||
|
profileImage: editBasic.profileImage,
|
||||||
|
};
|
||||||
|
await employeeService.update(editingEmployee.empId, updateData as Partial<Employee>);
|
||||||
|
// Update local list
|
||||||
|
setEmployees((prev) =>
|
||||||
|
prev.map((emp) =>
|
||||||
|
emp.empId === editingEmployee.empId
|
||||||
|
? { ...emp, ...editBasic, profileImage: editBasic.profileImage }
|
||||||
|
: emp
|
||||||
|
)
|
||||||
|
);
|
||||||
|
alert('Basic information updated successfully!');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setEditModalError(err.response?.data?.message || 'Failed to update employee. Please try again.');
|
setEditModalError(err.message || 'Failed to update basic information.');
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveStatus = async () => {
|
||||||
|
if (!editingEmployee) return;
|
||||||
|
|
||||||
|
if (editStatus.empStatus === 'RESIGNED' && !editStatus.leftDate) {
|
||||||
|
setEditModalError('Left date is required when changing to RESIGNED status.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdating(true);
|
||||||
|
setEditModalError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/employees/${editingEmployee.empId}/status`, {
|
||||||
|
empStatus: editStatus.empStatus,
|
||||||
|
leftDate: editStatus.empStatus === 'RESIGNED' ? editStatus.leftDate : null,
|
||||||
|
});
|
||||||
|
setEmployees((prev) =>
|
||||||
|
prev.map((emp) =>
|
||||||
|
emp.empId === editingEmployee.empId
|
||||||
|
? { ...emp, empStatus: editStatus.empStatus, leftDate: editStatus.empStatus === 'RESIGNED' ? editStatus.leftDate : null }
|
||||||
|
: emp
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setShowStatusConfirm(false);
|
||||||
|
setPendingStatus(null);
|
||||||
|
alert('Employee status updated successfully!');
|
||||||
|
} catch (err: any) {
|
||||||
|
setEditModalError(err.message || 'Failed to update employee status.');
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = (newStatus: EmpStatus) => {
|
||||||
|
if (newStatus === editingEmployee?.empStatus) return;
|
||||||
|
if (newStatus === 'RESIGNED') {
|
||||||
|
setPendingStatus(newStatus);
|
||||||
|
setShowStatusConfirm(true);
|
||||||
|
} else {
|
||||||
|
setEditStatus((prev) => ({ ...prev, empStatus: newStatus }));
|
||||||
|
// Auto-save for non-resigned changes
|
||||||
|
setEditStatus((prev) => ({ ...prev, empStatus: newStatus }));
|
||||||
|
// Trigger save
|
||||||
|
setTimeout(() => {
|
||||||
|
handleSaveStatus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmStatusChange = () => {
|
||||||
|
if (pendingStatus) {
|
||||||
|
setEditStatus((prev) => ({ ...prev, empStatus: pendingStatus }));
|
||||||
|
setShowStatusConfirm(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
handleSaveStatus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleChange = (newRole: RoleCode) => {
|
||||||
|
if (newRole === editingEmployee?.roleCode) return;
|
||||||
|
setPendingRole(newRole);
|
||||||
|
setShowRoleConfirm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRoleChange = async () => {
|
||||||
|
if (!editingEmployee || !pendingRole) return;
|
||||||
|
setIsUpdating(true);
|
||||||
|
setEditModalError(null);
|
||||||
|
setShowRoleConfirm(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/employees/${editingEmployee.empId}/role`, { roleCode: pendingRole });
|
||||||
|
setEditRole(pendingRole);
|
||||||
|
setEmployees((prev) =>
|
||||||
|
prev.map((emp) =>
|
||||||
|
emp.empId === editingEmployee.empId ? { ...emp, roleCode: pendingRole! } : emp
|
||||||
|
)
|
||||||
|
);
|
||||||
|
alert('Role updated successfully!');
|
||||||
|
} catch (err: any) {
|
||||||
|
setEditModalError(err.message || 'Failed to update role.');
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
setPendingRole(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDispatchStatusChange = async (newStatus: DispatchStatus) => {
|
||||||
|
if (!editingEmployee || newStatus === editingEmployee.dispatchStatus) return;
|
||||||
|
setIsUpdating(true);
|
||||||
|
setEditModalError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/employees/${editingEmployee.empId}/dispatch-status`, { dispatchStatus: newStatus });
|
||||||
|
setEditDispatchStatus(newStatus);
|
||||||
|
setEmployees((prev) =>
|
||||||
|
prev.map((emp) =>
|
||||||
|
emp.empId === editingEmployee.empId ? { ...emp, dispatchStatus: newStatus } : emp
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
setEditModalError(err.message || 'Failed to update dispatch status.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
}
|
}
|
||||||
@@ -254,6 +493,18 @@ export default function EmployeesPage() {
|
|||||||
{ label: '휴직', value: employees.filter((e) => e.empStatus === 'ON_LEAVE').length, color: 'text-status-critical-text' },
|
{ label: '휴직', value: employees.filter((e) => e.empStatus === 'ON_LEAVE').length, color: 'text-status-critical-text' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return new Date(dateStr).toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentDispatch = dispatchHistories.find((d) => !d.endDate) || null;
|
||||||
|
const pastDispatches = dispatchHistories.filter((d) => d.endDate);
|
||||||
|
|
||||||
|
const getProjectById = (projectId: number) => {
|
||||||
|
return projects.find((p) => p.projectId === projectId) || null;
|
||||||
|
};
|
||||||
|
|
||||||
// Add Employee Modal
|
// Add Employee Modal
|
||||||
if (isModalOpen) {
|
if (isModalOpen) {
|
||||||
return (
|
return (
|
||||||
@@ -396,129 +647,516 @@ export default function EmployeesPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit Employee Modal
|
// Edit Employee Modal - REDESIGNED
|
||||||
if (isEditModalOpen) {
|
if (isEditModalOpen) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 backdrop-blur-sm overflow-y-auto">
|
||||||
<div className="bg-surface rounded-2xl shadow-xl max-w-md w-full mx-4 p-6 relative animate-in fade-in zoom-in duration-200">
|
<div className="bg-surface rounded-2xl shadow-xl max-w-3xl w-full mx-4 my-8 relative animate-in fade-in zoom-in duration-200">
|
||||||
<button
|
{/* Header */}
|
||||||
onClick={handleCloseEditModal}
|
<div className="sticky top-0 bg-surface rounded-t-2xl border-b border-border px-6 py-4 flex items-center justify-between z-10">
|
||||||
className="absolute top-4 right-4 p-1.5 text-text-muted hover:text-text-main hover:bg-gray-100 rounded-lg transition-all"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<h3 className="font-outfit text-xl font-semibold text-text-main mb-1">Edit Employee</h3>
|
|
||||||
<p className="text-text-muted text-sm mb-6">Update employee information</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleUpdateSubmit} className="space-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-main mb-1.5">
|
<h3 className="font-outfit text-xl font-semibold text-text-main">Edit Employee</h3>
|
||||||
Name <span className="text-status-critical-text">*</span>
|
<p className="text-text-muted text-sm">{editingEmployee?.name} ({editingEmployee?.loginId})</p>
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
value={editData.name}
|
|
||||||
onChange={handleEditInputChange}
|
|
||||||
required
|
|
||||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
|
||||||
placeholder="Enter full name"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseEditModal}
|
||||||
|
className="p-1.5 text-text-muted hover:text-text-main hover:bg-gray-100 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
{editModalError && (
|
||||||
<label className="block text-sm font-medium text-text-main mb-1.5">Email</label>
|
<div className="mx-6 mt-4 bg-status-critical-bg border border-status-critical text-status-critical-text rounded-xl px-4 py-3 text-sm">
|
||||||
<input
|
{editModalError}
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
value={editData.email}
|
|
||||||
onChange={handleEditInputChange}
|
|
||||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
|
||||||
placeholder="email@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
{editLoading ? (
|
||||||
<label className="block text-sm font-medium text-text-main mb-1.5">Role</label>
|
<div className="p-6 space-y-6">
|
||||||
<select
|
{[1, 2, 3, 4].map((i) => (
|
||||||
name="roleCode"
|
<div key={i} className="bg-white rounded-2xl border border-border p-5">
|
||||||
value={editData.roleCode}
|
<div className="h-5 w-32 bg-gray-200 rounded mb-4 animate-pulse" />
|
||||||
onChange={handleEditInputChange}
|
<div className="grid grid-cols-2 gap-4">
|
||||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
{[1, 2, 3, 4].map((j) => (
|
||||||
>
|
<div key={j} className="h-10 bg-gray-100 rounded-xl animate-pulse" />
|
||||||
{roleCodesLoading ? (
|
))}
|
||||||
<option value="">Loading roles...</option>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
empRoleCodes.map((code) => (
|
))}
|
||||||
<option key={code.codeValue} value={code.codeValue}>
|
</div>
|
||||||
{code.codeName} ({code.codeValue})
|
) : (
|
||||||
</option>
|
<div className="p-6 space-y-6">
|
||||||
))
|
{/* SECTION 1: 기본 정보 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-border p-5">
|
||||||
|
<h4 className="font-outfit text-base font-semibold text-text-main mb-4 flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-5 bg-primary rounded-full" />
|
||||||
|
기본 정보
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Profile Image */}
|
||||||
|
<div className="flex items-center gap-4 mb-5">
|
||||||
|
<div className="relative">
|
||||||
|
{editBasic.profileImage ? (
|
||||||
|
<img
|
||||||
|
src={editBasic.profileImage}
|
||||||
|
alt={editBasic.name}
|
||||||
|
className="w-16 h-16 rounded-full object-cover border-2 border-border"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={`w-16 h-16 rounded-full ${getInitialColor(editBasic.name, 0)} flex items-center justify-center text-xl font-semibold text-white`}>
|
||||||
|
{editBasic.name[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleProfileImageUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 rounded-xl border border-border text-sm font-medium text-text-main hover:bg-gray-50 transition-all"
|
||||||
|
>
|
||||||
|
<Camera className="w-4 h-4" />
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
{editBasic.profileImage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveProfileImage}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 rounded-xl border border-border text-sm font-medium text-status-critical-text hover:bg-red-50 transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Basic Info Fields */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-muted mb-1">Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value={editBasic.name}
|
||||||
|
onChange={handleBasicInputChange}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-muted mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value={editBasic.email}
|
||||||
|
onChange={handleBasicInputChange}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-muted mb-1">Phone</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
name="phone"
|
||||||
|
value={editBasic.phone}
|
||||||
|
onChange={handleBasicInputChange}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||||
|
placeholder="010-0000-0000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-muted mb-1">Joined Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="joinedDate"
|
||||||
|
value={editBasic.joinedDate}
|
||||||
|
onChange={handleBasicInputChange}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-muted mb-1">Birth Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="birthDate"
|
||||||
|
value={editBasic.birthDate}
|
||||||
|
onChange={handleBasicInputChange}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-muted mb-1">Education</label>
|
||||||
|
<select
|
||||||
|
name="education"
|
||||||
|
value={editBasic.education}
|
||||||
|
onChange={handleBasicInputChange}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||||
|
>
|
||||||
|
<option value="">Select education</option>
|
||||||
|
<option value="HIGH_SCHOOL">High School</option>
|
||||||
|
<option value="ASSOCIATE">Associate</option>
|
||||||
|
<option value="BACHELOR">Bachelor</option>
|
||||||
|
<option value="MASTER">Master</option>
|
||||||
|
<option value="DOCTORATE">Doctorate</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-muted mb-1">Major</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="major"
|
||||||
|
value={editBasic.major}
|
||||||
|
onChange={handleBasicInputChange}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-muted mb-1">Graduation Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="graduationDate"
|
||||||
|
value={editBasic.graduationDate}
|
||||||
|
onChange={handleBasicInputChange}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-xs font-medium text-text-muted mb-1">Residence</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="residence"
|
||||||
|
value={editBasic.residence}
|
||||||
|
onChange={handleBasicInputChange}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-4 pt-4 border-t border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveBasicInfo}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="px-5 py-2.5 rounded-xl bg-primary hover:bg-primary-hover text-white text-sm font-semibold shadow-soft transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isUpdating ? 'Saving...' : 'Save Basic Info'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SECTION 2: 상태 & 권한 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-border p-5">
|
||||||
|
<h4 className="font-outfit text-base font-semibold text-text-main mb-4 flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-5 bg-secondary rounded-full" />
|
||||||
|
상태 & 권한
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{/* Employee Status */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-muted mb-1">Employee Status</label>
|
||||||
|
<select
|
||||||
|
value={editStatus.empStatus}
|
||||||
|
onChange={(e) => handleStatusChange(e.target.value as EmpStatus)}
|
||||||
|
className={`w-full px-3 py-2.5 rounded-xl border text-sm font-medium focus:outline-none focus:ring-2 transition-all ${
|
||||||
|
editStatus.empStatus === 'ACTIVE'
|
||||||
|
? 'bg-status-resolved-bg border-status-resolved text-status-resolved-text focus:ring-status-resolved'
|
||||||
|
: editStatus.empStatus === 'ON_LEAVE'
|
||||||
|
? 'bg-status-pending-bg border-status-pending text-status-pending-text focus:ring-status-pending'
|
||||||
|
: 'bg-status-critical-bg border-status-critical text-status-critical-text focus:ring-status-critical'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<option value="ACTIVE">재직 (ACTIVE)</option>
|
||||||
|
<option value="ON_LEAVE">휴직 (ON_LEAVE)</option>
|
||||||
|
<option value="RESIGNED">퇴사 (RESIGNED)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-muted mb-1">Role</label>
|
||||||
|
<select
|
||||||
|
value={editRole}
|
||||||
|
onChange={(e) => handleRoleChange(e.target.value as RoleCode)}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||||
|
>
|
||||||
|
{roleCodesLoading ? (
|
||||||
|
<option value="">Loading...</option>
|
||||||
|
) : (
|
||||||
|
empRoleCodes.map((code) => (
|
||||||
|
<option key={code.codeValue} value={code.codeValue}>
|
||||||
|
{code.codeName}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dispatch Status */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-muted mb-1">Dispatch Status</label>
|
||||||
|
<select
|
||||||
|
value={editDispatchStatus}
|
||||||
|
onChange={(e) => handleDispatchStatusChange(e.target.value as DispatchStatus)}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||||
|
>
|
||||||
|
<option value="WAITING">대기 (WAITING)</option>
|
||||||
|
<option value="DISPATCHED">파견중 (DISPATCHED)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Left Date (shown when RESIGNED) */}
|
||||||
|
{editStatus.empStatus === 'RESIGNED' && (
|
||||||
|
<div className="mt-4 p-4 bg-status-critical-bg rounded-xl border border-status-critical/20">
|
||||||
|
<label className="block text-xs font-medium text-status-critical-text mb-1">Left Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={editStatus.leftDate}
|
||||||
|
onChange={(e) => setEditStatus((prev) => ({ ...prev, leftDate: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-white border border-status-critical/30 text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-status-critical transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-main mb-1.5">Employee Status</label>
|
|
||||||
<select
|
|
||||||
name="empStatus"
|
|
||||||
value={editData.empStatus}
|
|
||||||
onChange={handleEditInputChange}
|
|
||||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
|
||||||
>
|
|
||||||
{statusCodesLoading ? (
|
|
||||||
<option value="">Loading status...</option>
|
|
||||||
) : (
|
|
||||||
empStatusCodes.map((code) => (
|
|
||||||
<option key={code.codeValue} value={code.codeValue}>
|
|
||||||
{code.codeName} ({code.codeValue})
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* SECTION 3: 현재 지급 장비 */}
|
||||||
<label className="block text-sm font-medium text-text-main mb-1.5">Dispatch Status</label>
|
<div className="bg-white rounded-2xl border border-border p-5">
|
||||||
<select
|
<h4 className="font-outfit text-base font-semibold text-text-main mb-4 flex items-center gap-2">
|
||||||
name="dispatchStatus"
|
<span className="w-1.5 h-5 bg-tertiary rounded-full" />
|
||||||
value={editData.dispatchStatus}
|
현재 지급 장비
|
||||||
onChange={handleEditInputChange}
|
</h4>
|
||||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
|
||||||
>
|
{equipmentList.length === 0 ? (
|
||||||
<option value="WAITING">대기 (WAITING)</option>
|
<div className="text-center py-6 text-text-muted text-sm">
|
||||||
<option value="DISPATCHED">파견중 (DISPATCHED)</option>
|
No equipment currently assigned.
|
||||||
</select>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="py-2 px-3 text-xs font-semibold text-text-muted uppercase">자산코드</th>
|
||||||
|
<th className="py-2 px-3 text-xs font-semibold text-text-muted uppercase">모델명</th>
|
||||||
|
<th className="py-2 px-3 text-xs font-semibold text-text-muted uppercase">제조사</th>
|
||||||
|
<th className="py-2 px-3 text-xs font-semibold text-text-muted uppercase">시리얼번호</th>
|
||||||
|
<th className="py-2 px-3 text-xs font-semibold text-text-muted uppercase">지급일</th>
|
||||||
|
<th className="py-2 px-3 text-xs font-semibold text-text-muted uppercase">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{equipmentList
|
||||||
|
.filter((eq) => !eq.returnedDate)
|
||||||
|
.map((eq) => (
|
||||||
|
<tr key={eq.assignmentId} className="hover:bg-gray-50">
|
||||||
|
<td className="py-2.5 px-3 font-mono text-xs">{eq.equipment?.assetCode || '-'}</td>
|
||||||
|
<td className="py-2.5 px-3">{eq.equipment?.modelName || '-'}</td>
|
||||||
|
<td className="py-2.5 px-3">{eq.equipment?.manufacturer || '-'}</td>
|
||||||
|
<td className="py-2.5 px-3 font-mono text-xs">{eq.equipment?.serialNumber || '-'}</td>
|
||||||
|
<td className="py-2.5 px-3">{formatDate(eq.assignedDate)}</td>
|
||||||
|
<td className="py-2.5 px-3">
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-status-pending-bg text-status-pending-text">
|
||||||
|
사용중
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-1.5 text-xs text-text-muted">
|
||||||
|
<LinkIcon className="w-3.5 h-3.5" />
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/itam')}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Manage equipment in ITAM →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SECTION 4: 파견 정보 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-border p-5">
|
||||||
|
<h4 className="font-outfit text-base font-semibold text-text-main mb-4 flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-5 bg-status-open rounded-full" />
|
||||||
|
파견 정보
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Current Dispatch */}
|
||||||
|
{currentDispatch ? (
|
||||||
|
<div className="mb-5 p-4 bg-status-open-bg rounded-xl border border-status-open/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Briefcase className="w-4 h-4 text-status-open" />
|
||||||
|
<span className="text-sm font-semibold text-text-main">현재 파견중</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-text-muted">프로젝트</span>
|
||||||
|
<p className="font-medium text-text-main">{currentDispatch.projectName || '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-text-muted">역할</span>
|
||||||
|
<p className="font-medium text-text-main">{currentDispatch.roleTitle || '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-text-muted">기간</span>
|
||||||
|
<p className="font-medium text-text-main">
|
||||||
|
{formatDate(currentDispatch.startDate)} ~ 진행중
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-text-muted">근무지</span>
|
||||||
|
<p className="font-medium text-text-main flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{getProjectById(currentDispatch.projectId)?.workAddress || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mb-5 p-4 bg-gray-50 rounded-xl text-center text-sm text-text-muted">
|
||||||
|
현재 파견된 프로젝트가 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dispatch History */}
|
||||||
|
{pastDispatches.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-semibold text-text-main mb-3 flex items-center gap-1.5">
|
||||||
|
<Clock className="w-4 h-4 text-text-muted" />
|
||||||
|
파견 이력 ({pastDispatches.length})
|
||||||
|
</h5>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pastDispatches.map((dh) => (
|
||||||
|
<div
|
||||||
|
key={dh.dispatchId}
|
||||||
|
className="flex items-center justify-between p-3 rounded-xl border border-border hover:bg-gray-50 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text-main truncate">{dh.projectName || 'Project'}</p>
|
||||||
|
<p className="text-xs text-text-muted">{dh.roleTitle}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right flex-shrink-0 ml-4">
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
{formatDate(dh.startDate)} ~ {formatDate(dh.endDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-1.5 text-xs text-text-muted">
|
||||||
|
<LinkIcon className="w-3.5 h-3.5" />
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/profile/${editingEmployee?.empId}`)}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
View full portfolio →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{editModalError && (
|
{/* Modal Footer */}
|
||||||
<div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-xl px-4 py-3 text-sm">
|
<div className="sticky bottom-0 bg-surface rounded-b-2xl border-t border-border px-6 py-4 flex justify-end gap-3">
|
||||||
{editModalError}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
)}
|
onClick={handleCloseEditModal}
|
||||||
|
disabled={isUpdating}
|
||||||
<div className="flex gap-3 pt-4">
|
className="px-5 py-2.5 rounded-xl border border-border text-text-main text-sm font-medium hover:bg-gray-100 transition-all disabled:opacity-50"
|
||||||
<button
|
>
|
||||||
type="button"
|
Close
|
||||||
onClick={handleCloseEditModal}
|
</button>
|
||||||
disabled={isUpdating}
|
</div>
|
||||||
className="flex-1 px-4 py-2.5 rounded-xl border border-border text-text-main text-sm font-medium hover:bg-gray-100 transition-all disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isUpdating}
|
|
||||||
className="flex-1 px-4 py-2.5 rounded-xl bg-primary hover:bg-primary-hover text-white text-sm font-semibold shadow-soft transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isUpdating ? 'Updating...' : 'Save Changes'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Role Change Confirmation Dialog */}
|
||||||
|
{showRoleConfirm && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-surface rounded-2xl shadow-xl max-w-sm w-full mx-4 p-6 animate-in fade-in zoom-in duration-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-status-pending-bg flex items-center justify-center flex-shrink-0">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-status-pending-text" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-outfit text-lg font-semibold text-text-main">Confirm Role Change</h4>
|
||||||
|
<p className="text-text-muted text-sm">Change role for {editingEmployee?.name}?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-xl px-4 py-3 mb-5">
|
||||||
|
<p className="text-sm text-text-main">
|
||||||
|
Role will change from{' '}
|
||||||
|
<span className="font-semibold">{roleLabel[editingEmployee?.roleCode || 'EMPLOYEE'] || editingEmployee?.roleCode}</span>
|
||||||
|
{' '}to{' '}
|
||||||
|
<span className="font-semibold">{roleLabel[pendingRole || 'EMPLOYEE'] || pendingRole}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowRoleConfirm(false); setPendingRole(null); }}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl border border-border text-text-main text-sm font-medium hover:bg-gray-100 transition-all"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={confirmRoleChange}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl bg-primary hover:bg-primary-hover text-white text-sm font-semibold shadow-soft transition-all"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Change Confirmation Dialog (for RESIGNED) */}
|
||||||
|
{showStatusConfirm && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-surface rounded-2xl shadow-xl max-w-sm w-full mx-4 p-6 animate-in fade-in zoom-in duration-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-status-critical-bg flex items-center justify-center flex-shrink-0">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-status-critical-text" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-outfit text-lg font-semibold text-text-main">Confirm Status Change</h4>
|
||||||
|
<p className="text-text-muted text-sm">Change status to RESIGNED?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-status-critical-bg border border-status-critical/20 rounded-xl px-4 py-3 mb-5">
|
||||||
|
<p className="text-sm text-status-critical-text">
|
||||||
|
This will mark <span className="font-semibold">{editingEmployee?.name}</span> as resigned.
|
||||||
|
Please ensure the left date is set.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowStatusConfirm(false); setPendingStatus(null); }}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl border border-border text-text-main text-sm font-medium hover:bg-gray-100 transition-all"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={confirmStatusChange}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl bg-status-critical hover:bg-status-critical-hover text-white text-sm font-semibold shadow-soft transition-all"
|
||||||
|
>
|
||||||
|
Confirm Resignation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,6 +225,15 @@ export interface EquipmentAssignment {
|
|||||||
returnedDate: string | null;
|
returnedDate: string | null;
|
||||||
condOnAssign: string;
|
condOnAssign: string;
|
||||||
condOnReturn: string | null;
|
condOnReturn: string | null;
|
||||||
|
equipment?: {
|
||||||
|
equipmentId: number;
|
||||||
|
assetCode: string;
|
||||||
|
manufacturer: string;
|
||||||
|
modelName: string;
|
||||||
|
serialNumber: string;
|
||||||
|
equipmentType: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EquipmentMaintenance {
|
export interface EquipmentMaintenance {
|
||||||
|
|||||||
Reference in New Issue
Block a user