feat: add employee fields (birthDate, education, residence) + emergency contacts

This commit is contained in:
pray4skylark
2026-06-02 18:11:05 +09:00
parent 65a82db0e0
commit d205a447a1
12 changed files with 747 additions and 112 deletions
@@ -9,6 +9,7 @@ import com.srsystem.dto.PortfolioResponse;
import com.srsystem.dto.UpdateAchievementsRequest; 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.enums.EmpStatus; import com.srsystem.entity.enums.EmpStatus;
import com.srsystem.service.DispatchHistoryService; import com.srsystem.service.DispatchHistoryService;
import com.srsystem.service.EmployeeService; import com.srsystem.service.EmployeeService;
@@ -152,6 +153,39 @@ 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')")
@GetMapping("/{empId}/emergency-contacts")
public ResponseEntity<ApiResponse<List<EmployeeEmergencyContact>>> getEmergencyContacts(@PathVariable Long empId) {
return ResponseEntity.ok(ApiResponse.ok(employeeService.getEmergencyContacts(empId)));
}
@Operation(summary = "비상연락망 추가", description = "직원의 비상연락망을 추가합니다")
@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
@PostMapping("/{empId}/emergency-contacts")
public ResponseEntity<ApiResponse<EmployeeEmergencyContact>> createEmergencyContact(
@PathVariable Long empId, @RequestBody EmployeeEmergencyContact contact) {
return ResponseEntity.ok(ApiResponse.ok("비상연락망이 추가되었습니다", employeeService.createEmergencyContact(empId, contact)));
}
@Operation(summary = "비상연락망 수정", description = "비상연락망 정보를 수정합니다")
@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
@PutMapping("/{empId}/emergency-contacts/{contactId}")
public ResponseEntity<ApiResponse<EmployeeEmergencyContact>> updateEmergencyContact(
@PathVariable Long empId, @PathVariable Long contactId, @RequestBody EmployeeEmergencyContact contact) {
return ResponseEntity.ok(ApiResponse.ok("비상연락망이 수정되었습니다", employeeService.updateEmergencyContact(contactId, contact)));
}
@Operation(summary = "비상연락망 삭제", description = "비상연락망을 삭제합니다")
@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
@DeleteMapping("/{empId}/emergency-contacts/{contactId}")
public ResponseEntity<ApiResponse<Void>> deleteEmergencyContact(@PathVariable Long empId, @PathVariable Long contactId) {
employeeService.deleteEmergencyContact(contactId);
return ResponseEntity.ok(ApiResponse.ok("비상연락망이 삭제되었습니다", null));
}
@Operation(summary = "비밀번호 변경", description = "현재 사용자의 비밀번호를 변경합니다") @Operation(summary = "비밀번호 변경", description = "현재 사용자의 비밀번호를 변경합니다")
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
@PutMapping("/me/password") @PutMapping("/me/password")
@@ -15,5 +15,10 @@ public record EmployeeSummary(
EmpStatus empStatus, EmpStatus empStatus,
DispatchStatus dispatchStatus, DispatchStatus dispatchStatus,
LocalDate joinedDate, LocalDate joinedDate,
LocalDate leftDate LocalDate leftDate,
LocalDate birthDate,
String education,
String major,
LocalDate graduationDate,
String residence
) {} ) {}
@@ -10,6 +10,7 @@ import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction; import org.hibernate.annotations.SQLRestriction;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List;
@Entity @Entity
@Table(name = "tb_employee") @Table(name = "tb_employee")
@@ -60,4 +61,23 @@ public class Employee extends BaseEntity {
@Column(name = "left_date") @Column(name = "left_date")
private LocalDate leftDate; private LocalDate leftDate;
@Column(name = "birth_date")
private LocalDate birthDate;
@Column(name = "education", length = 20)
private String education;
@Column(name = "major", length = 100)
private String major;
@Column(name = "graduation_date")
private LocalDate graduationDate;
@Column(name = "residence", length = 255)
private String residence;
@OneToMany(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("sortOrder ASC")
private List<EmployeeEmergencyContact> emergencyContacts;
} }
@@ -0,0 +1,42 @@
package com.srsystem.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
@Entity
@Table(name = "tb_employee_emergency_contact")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@SQLDelete(sql = "UPDATE tb_employee_emergency_contact SET is_deleted = 'Y' WHERE id = ?")
@SQLRestriction("is_deleted = 'N'")
public class EmployeeEmergencyContact extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_employee_emergency_contacts")
@SequenceGenerator(name = "sq_employee_emergency_contacts", sequenceName = "sq_employee_emergency_contacts", allocationSize = 50)
@Column(name = "id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "emp_id", nullable = false)
@JsonIgnore
private Employee employee;
@Column(name = "name", nullable = false, length = 100)
private String name;
@Column(name = "relation", length = 50)
private String relation;
@Column(name = "phone", length = 20)
private String phone;
@Column(name = "sort_order")
private Integer sortOrder;
}
@@ -0,0 +1,10 @@
package com.srsystem.repository;
import com.srsystem.entity.EmployeeEmergencyContact;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface EmployeeEmergencyContactRepository extends JpaRepository<EmployeeEmergencyContact, Long> {
List<EmployeeEmergencyContact> findByEmployeeEmpIdOrderBySortOrderAsc(Long empId);
void deleteByEmployeeEmpId(Long empId);
}
@@ -23,6 +23,7 @@ public class AuthService {
private final EmployeeRepository employeeRepository; private final EmployeeRepository employeeRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final EmployeeService employeeService;
@Transactional @Transactional
public LoginResponse login(LoginRequest request) { public LoginResponse login(LoginRequest request) {
@@ -36,17 +37,7 @@ public class AuthService {
String accessToken = jwtTokenProvider.generateAccessToken(employee.getLoginId(), employee.getRoleCode().name()); String accessToken = jwtTokenProvider.generateAccessToken(employee.getLoginId(), employee.getRoleCode().name());
String refreshToken = jwtTokenProvider.generateRefreshToken(employee.getLoginId()); String refreshToken = jwtTokenProvider.generateRefreshToken(employee.getLoginId());
EmployeeSummary summary = new EmployeeSummary( EmployeeSummary summary = employeeService.toSummary(employee);
employee.getEmpId(),
employee.getName(),
employee.getEmail(),
employee.getPhone(),
employee.getRoleCode(),
employee.getEmpStatus(),
employee.getDispatchStatus(),
employee.getJoinedDate(),
employee.getLeftDate()
);
return new LoginResponse(accessToken, refreshToken, summary); return new LoginResponse(accessToken, refreshToken, summary);
} }
@@ -64,17 +55,7 @@ public class AuthService {
String newAccessToken = jwtTokenProvider.generateAccessToken(employee.getLoginId(), employee.getRoleCode().name()); String newAccessToken = jwtTokenProvider.generateAccessToken(employee.getLoginId(), employee.getRoleCode().name());
String newRefreshToken = jwtTokenProvider.generateRefreshToken(employee.getLoginId()); String newRefreshToken = jwtTokenProvider.generateRefreshToken(employee.getLoginId());
EmployeeSummary summary = new EmployeeSummary( EmployeeSummary summary = employeeService.toSummary(employee);
employee.getEmpId(),
employee.getName(),
employee.getEmail(),
employee.getPhone(),
employee.getRoleCode(),
employee.getEmpStatus(),
employee.getDispatchStatus(),
employee.getJoinedDate(),
employee.getLeftDate()
);
return new LoginResponse(newAccessToken, newRefreshToken, summary); return new LoginResponse(newAccessToken, newRefreshToken, summary);
} }
@@ -4,10 +4,12 @@ import com.srsystem.dto.ChangePasswordRequest;
import com.srsystem.dto.EmployeeSearchResult; 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.enums.EmpStatus; import com.srsystem.entity.enums.EmpStatus;
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;
import com.srsystem.repository.EmployeeEmergencyContactRepository;
import com.srsystem.repository.EmployeeRepository; import com.srsystem.repository.EmployeeRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@@ -28,6 +30,7 @@ public class EmployeeService {
private final EmployeeRepository employeeRepository; private final EmployeeRepository employeeRepository;
private final DispatchHistoryRepository dispatchHistoryRepository; private final DispatchHistoryRepository dispatchHistoryRepository;
private final EmployeeEmergencyContactRepository emergencyContactRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
public Page<Employee> findAll(EmpStatus status, Pageable pageable) { public Page<Employee> findAll(EmpStatus status, Pageable pageable) {
@@ -101,6 +104,11 @@ public class EmployeeService {
employee.setDispatchStatus(updateRequest.getDispatchStatus()); employee.setDispatchStatus(updateRequest.getDispatchStatus());
employee.setJoinedDate(updateRequest.getJoinedDate()); employee.setJoinedDate(updateRequest.getJoinedDate());
employee.setLeftDate(updateRequest.getLeftDate()); employee.setLeftDate(updateRequest.getLeftDate());
employee.setBirthDate(updateRequest.getBirthDate());
employee.setEducation(updateRequest.getEducation());
employee.setMajor(updateRequest.getMajor());
employee.setGraduationDate(updateRequest.getGraduationDate());
employee.setResidence(updateRequest.getResidence());
if (updateRequest.getPassword() != null && !updateRequest.getPassword().isBlank()) { if (updateRequest.getPassword() != null && !updateRequest.getPassword().isBlank()) {
employee.setPassword(passwordEncoder.encode(updateRequest.getPassword())); employee.setPassword(passwordEncoder.encode(updateRequest.getPassword()));
@@ -130,10 +138,48 @@ public class EmployeeService {
employee.getEmpStatus(), employee.getEmpStatus(),
employee.getDispatchStatus(), employee.getDispatchStatus(),
employee.getJoinedDate(), employee.getJoinedDate(),
employee.getLeftDate() employee.getLeftDate(),
employee.getBirthDate(),
employee.getEducation(),
employee.getMajor(),
employee.getGraduationDate(),
employee.getResidence()
); );
} }
// ---- Emergency Contact CRUD ----
public List<EmployeeEmergencyContact> getEmergencyContacts(Long empId) {
return emergencyContactRepository.findByEmployeeEmpIdOrderBySortOrderAsc(empId);
}
@Transactional
public EmployeeEmergencyContact createEmergencyContact(Long empId, EmployeeEmergencyContact contact) {
Employee employee = findById(empId);
contact.setEmployee(employee);
if (contact.getSortOrder() == null) contact.setSortOrder(0);
return emergencyContactRepository.save(contact);
}
@Transactional
public EmployeeEmergencyContact updateEmergencyContact(Long contactId, EmployeeEmergencyContact update) {
EmployeeEmergencyContact contact = emergencyContactRepository.findById(contactId)
.orElseThrow(() -> new BusinessException(ErrorCode.CODE_NOT_FOUND));
if (update.getName() != null) contact.setName(update.getName());
if (update.getRelation() != null) contact.setRelation(update.getRelation());
if (update.getPhone() != null) contact.setPhone(update.getPhone());
if (update.getSortOrder() != null) contact.setSortOrder(update.getSortOrder());
return emergencyContactRepository.save(contact);
}
@Transactional
public void deleteEmergencyContact(Long contactId) {
EmployeeEmergencyContact contact = emergencyContactRepository.findById(contactId)
.orElseThrow(() -> new BusinessException(ErrorCode.CODE_NOT_FOUND));
contact.setIsDeleted('Y');
emergencyContactRepository.save(contact);
}
@Transactional @Transactional
public void changePassword(Long empId, ChangePasswordRequest request) { public void changePassword(Long empId, ChangePasswordRequest request) {
Employee employee = findById(empId); Employee employee = findById(empId);
@@ -0,0 +1,41 @@
ALTER TABLE tb_employee ADD COLUMN birth_date DATE;
ALTER TABLE tb_employee ADD COLUMN education VARCHAR(20);
ALTER TABLE tb_employee ADD COLUMN major VARCHAR(100);
ALTER TABLE tb_employee ADD COLUMN graduation_date DATE;
ALTER TABLE tb_employee ADD COLUMN residence VARCHAR(255);
CREATE SEQUENCE sq_employee_emergency_contacts INCREMENT BY 50;
CREATE TABLE tb_employee_emergency_contact (
id BIGSERIAL PRIMARY KEY,
emp_id BIGINT NOT NULL REFERENCES tb_employee(id),
name VARCHAR(100) NOT NULL,
relation VARCHAR(50),
phone VARCHAR(20),
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP,
updated_at TIMESTAMP,
is_deleted CHAR(1) DEFAULT 'N'
);
CREATE INDEX idx_emergency_contact_emp_id ON tb_employee_emergency_contact(emp_id);
INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at)
SELECT nextval('sq_common_codes'), 'EDUCATION_LEVEL', 'HIGH_SCHOOL', '고졸', 1, 'Y', NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'EDUCATION_LEVEL' AND code_value = 'HIGH_SCHOOL');
INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at)
SELECT nextval('sq_common_codes'), 'EDUCATION_LEVEL', 'COLLEGE', '전문대', 2, 'Y', NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'EDUCATION_LEVEL' AND code_value = 'COLLEGE');
INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at)
SELECT nextval('sq_common_codes'), 'EDUCATION_LEVEL', 'BACHELOR', '학사', 3, 'Y', NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'EDUCATION_LEVEL' AND code_value = 'BACHELOR');
INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at)
SELECT nextval('sq_common_codes'), 'EDUCATION_LEVEL', 'MASTER', '석사', 4, 'Y', NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'EDUCATION_LEVEL' AND code_value = 'MASTER');
INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at)
SELECT nextval('sq_common_codes'), 'EDUCATION_LEVEL', 'DOCTOR', '박사', 5, 'Y', NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'EDUCATION_LEVEL' AND code_value = 'DOCTOR');
+310 -3
View File
@@ -2,18 +2,27 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { UserCircle, Mail, Briefcase, Calendar, Lock, Shield, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; import { UserCircle, Mail, Briefcase, Calendar, Lock, Shield, CheckCircle, XCircle, AlertCircle, Phone, Plus, Pencil, Trash2, X, Save, GraduationCap, MapPin } from 'lucide-react';
import employeeService from '@/services/employeeService'; import employeeService from '@/services/employeeService';
import type { ApiResponse, Employee, DispatchHistory } from '@/types'; import type { ApiResponse, Employee, DispatchHistory, EmergencyContact } from '@/types';
interface EmployeeWithId extends Employee { interface EmployeeWithId extends Employee {
empId: number; empId: number;
} }
const EDUCATION_LABELS: Record<string, string> = {
HIGH_SCHOOL: '고졸',
COLLEGE: '전문대',
BACHELOR: '학사',
MASTER: '석사',
DOCTOR: '박사',
};
export default function ProfilePage() { export default function ProfilePage() {
const router = useRouter(); const router = useRouter();
const [employee, setEmployee] = useState<EmployeeWithId | null>(null); const [employee, setEmployee] = useState<EmployeeWithId | null>(null);
const [dispatchHistories, setDispatchHistories] = useState<DispatchHistory[]>([]); const [dispatchHistories, setDispatchHistories] = useState<DispatchHistory[]>([]);
const [emergencyContacts, setEmergencyContacts] = useState<EmergencyContact[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -25,6 +34,13 @@ export default function ProfilePage() {
const [passwordSuccess, setPasswordSuccess] = useState<string | null>(null); const [passwordSuccess, setPasswordSuccess] = useState<string | null>(null);
const [isChangingPassword, setIsChangingPassword] = useState(false); const [isChangingPassword, setIsChangingPassword] = useState(false);
// Emergency contact modal state
const [contactModalOpen, setContactModalOpen] = useState(false);
const [editingContact, setEditingContact] = useState<EmergencyContact | null>(null);
const [contactForm, setContactForm] = useState({ name: '', relation: '', phone: '' });
const [contactFormError, setContactFormError] = useState<string | null>(null);
const [isSavingContact, setIsSavingContact] = useState(false);
useEffect(() => { useEffect(() => {
loadProfile(); loadProfile();
}, []); }, []);
@@ -43,6 +59,13 @@ export default function ProfilePage() {
const data = historyRes.data; const data = historyRes.data;
setDispatchHistories(Array.isArray(data) ? data : []); setDispatchHistories(Array.isArray(data) ? data : []);
} }
// Load emergency contacts
const contactRes = await employeeService.getEmergencyContacts(empData.empId);
if (contactRes.success) {
const data = contactRes.data;
setEmergencyContacts(Array.isArray(data) ? data : []);
}
} }
} catch (err) { } catch (err) {
setError('프로필 정보를 불러오는데 실패했습니다.'); setError('프로필 정보를 불러오는데 실패했습니다.');
@@ -83,6 +106,82 @@ export default function ProfilePage() {
} }
}; };
const openContactModal = (contact?: EmergencyContact) => {
if (contact) {
setEditingContact(contact);
setContactForm({
name: contact.name,
relation: contact.relation || '',
phone: contact.phone || '',
});
} else {
setEditingContact(null);
setContactForm({ name: '', relation: '', phone: '' });
}
setContactFormError(null);
setContactModalOpen(true);
};
const closeContactModal = () => {
setContactModalOpen(false);
setEditingContact(null);
setContactForm({ name: '', relation: '', phone: '' });
setContactFormError(null);
};
const handleSaveContact = async (e: React.FormEvent) => {
e.preventDefault();
setContactFormError(null);
if (!contactForm.name.trim()) {
setContactFormError('이름을 입력해주세요.');
return;
}
if (!contactForm.phone.trim()) {
setContactFormError('전화번호를 입력해주세요.');
return;
}
try {
setIsSavingContact(true);
if (editingContact) {
const res = await employeeService.updateEmergencyContact(employee!.empId, editingContact.id, contactForm);
if (res.success) {
const contactRes = await employeeService.getEmergencyContacts(employee!.empId);
if (contactRes.success) {
setEmergencyContacts(Array.isArray(contactRes.data) ? contactRes.data : []);
}
closeContactModal();
}
} else {
const res = await employeeService.createEmergencyContact(employee!.empId, contactForm);
if (res.success) {
const contactRes = await employeeService.getEmergencyContacts(employee!.empId);
if (contactRes.success) {
setEmergencyContacts(Array.isArray(contactRes.data) ? contactRes.data : []);
}
closeContactModal();
}
}
} catch (err: any) {
setContactFormError(err.response?.data?.message || '저장에 실패했습니다.');
} finally {
setIsSavingContact(false);
}
};
const handleDeleteContact = async (contactId: number) => {
if (!confirm('비상 연락처를 삭제하시겠습니까?')) return;
try {
const res = await employeeService.deleteEmergencyContact(employee!.empId, contactId);
if (res.success) {
setEmergencyContacts((prev) => prev.filter((c) => c.id !== contactId));
}
} catch (err: any) {
alert(err.response?.data?.message || '삭제에 실패했습니다.');
}
};
const getRoleBadgeColor = (roleCode: string) => { const getRoleBadgeColor = (roleCode: string) => {
switch (roleCode) { switch (roleCode) {
case 'ADMIN': case 'ADMIN':
@@ -122,6 +221,11 @@ export default function ProfilePage() {
const formatInitial = (name: string) => name.charAt(0).toUpperCase(); const formatInitial = (name: string) => name.charAt(0).toUpperCase();
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return dateStr;
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-background"> <div className="flex items-center justify-center min-h-screen bg-background">
@@ -152,7 +256,7 @@ export default function ProfilePage() {
return ( return (
<div className="min-h-screen bg-background p-4 sm:p-6 lg:p-8"> <div className="min-h-screen bg-background p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto space-y-8"> <div className="max-w-5xl mx-auto space-y-8">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="font-outfit text-2xl font-semibold text-text-main">내 프로필</h1> <h1 className="font-outfit text-2xl font-semibold text-text-main">내 프로필</h1>
@@ -226,6 +330,122 @@ export default function ProfilePage() {
</div> </div>
</div> </div>
{/* Personal Info */}
<div className="bg-surface border border-border rounded-2xl p-6 shadow-soft">
<div className="flex items-center gap-3 mb-6">
<UserCircle className="w-5 h-5 text-primary" />
<h2 className="font-outfit text-lg font-semibold text-text-main">개인 정보</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<Calendar className="w-4 h-4" />
<span>생년월일</span>
</div>
<p className="text-text-main font-medium">{formatDate(employee.birthDate)}</p>
</div>
<div>
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<GraduationCap className="w-4 h-4" />
<span>학력</span>
</div>
<p className="text-text-main font-medium">{employee.education ? (EDUCATION_LABELS[employee.education] || employee.education) : '-'}</p>
</div>
<div>
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<GraduationCap className="w-4 h-4" />
<span>학과</span>
</div>
<p className="text-text-main font-medium">{employee.major || '-'}</p>
</div>
<div>
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<Calendar className="w-4 h-4" />
<span>졸업일</span>
</div>
<p className="text-text-main font-medium">{formatDate(employee.graduationDate)}</p>
</div>
<div className="sm:col-span-2">
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<MapPin className="w-4 h-4" />
<span>현재 거주지</span>
</div>
<p className="text-text-main font-medium">{employee.residence || '-'}</p>
</div>
</div>
</div>
{/* Emergency Contacts */}
<div className="bg-surface border border-border rounded-2xl p-6 shadow-soft">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Phone className="w-5 h-5 text-primary" />
<h2 className="font-outfit text-lg font-semibold text-text-main">비상 연락처</h2>
</div>
<button
onClick={() => openContactModal()}
className="flex items-center gap-1 px-4 py-2 bg-primary text-white rounded-xl text-sm font-bold hover:bg-primary/90 transition-colors shadow-lg shadow-primary/20"
>
<Plus className="w-4 h-4" />
추가
</button>
</div>
{emergencyContacts.length === 0 ? (
<div className="text-center py-12">
<Phone className="w-12 h-12 text-text-muted/30 mx-auto mb-4" />
<p className="text-text-muted">등록된 비상 연락처가 없습니다.</p>
</div>
) : (
<div className="space-y-3">
{emergencyContacts
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((contact) => (
<div
key={contact.id}
className="flex items-center justify-between border border-border rounded-xl p-4 hover:bg-surface-container-low transition-colors"
>
<div className="flex-1 grid grid-cols-3 gap-4">
<div>
<span className="text-text-muted text-xs">이름</span>
<p className="text-text-main font-medium">{contact.name}</p>
</div>
<div>
<span className="text-text-muted text-xs">관계</span>
<p className="text-text-main font-medium">{contact.relation || '-'}</p>
</div>
<div>
<span className="text-text-muted text-xs">전화번호</span>
<p className="text-text-main font-medium">{contact.phone || '-'}</p>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => openContactModal(contact)}
className="p-2 text-text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
title="수정"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteContact(contact.id)}
className="p-2 text-text-muted hover:text-error hover:bg-error/10 rounded-lg transition-colors"
title="삭제"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Dispatch History */} {/* Dispatch History */}
<div className="bg-surface border border-border rounded-2xl p-6 shadow-soft"> <div className="bg-surface border border-border rounded-2xl p-6 shadow-soft">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@@ -355,6 +575,93 @@ export default function ProfilePage() {
</form> </form>
</div> </div>
</div> </div>
{/* Emergency Contact Modal */}
{contactModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
<div className="bg-surface border border-border rounded-2xl shadow-soft w-full max-w-md">
<div className="flex items-center justify-between p-6 border-b border-border">
<h3 className="font-outfit text-lg font-semibold text-text-main">
{editingContact ? '비상 연락처 수정' : '비상 연락처 추가'}
</h3>
<button
onClick={closeContactModal}
className="p-2 text-text-muted hover:text-text-main hover:bg-surface-container rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSaveContact} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-text-muted mb-2">
이름 *
</label>
<input
type="text"
value={contactForm.name}
onChange={(e) => setContactForm((prev) => ({ ...prev, name: e.target.value }))}
className="w-full px-4 py-3 border border-border rounded-xl bg-surface-container-lowest text-text-main placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
placeholder="이름을 입력하세요"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-muted mb-2">
관계
</label>
<input
type="text"
value={contactForm.relation}
onChange={(e) => setContactForm((prev) => ({ ...prev, relation: e.target.value }))}
className="w-full px-4 py-3 border border-border rounded-xl bg-surface-container-lowest text-text-main placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
placeholder="예: 배우자, 부모, 형제"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-muted mb-2">
전화번호 *
</label>
<input
type="tel"
value={contactForm.phone}
onChange={(e) => setContactForm((prev) => ({ ...prev, phone: e.target.value }))}
className="w-full px-4 py-3 border border-border rounded-xl bg-surface-container-lowest text-text-main placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
placeholder="010-0000-0000"
required
/>
</div>
{contactFormError && (
<div className="flex items-center gap-2 text-error text-sm bg-error-container/30 px-4 py-3 rounded-xl">
<XCircle className="w-4 h-4" />
<span>{contactFormError}</span>
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={closeContactModal}
className="flex-1 py-3 border border-border text-text-main rounded-xl font-bold text-sm hover:bg-surface-container transition-colors"
>
취소
</button>
<button
type="submit"
disabled={isSavingContact}
className="flex-1 flex items-center justify-center gap-2 py-3 bg-primary text-white rounded-xl font-bold text-sm hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-primary/20"
>
<Save className="w-4 h-4" />
{isSavingContact ? '저장중...' : '저장'}
</button>
</div>
</form>
</div>
</div>
)}
</div> </div>
); );
} }
+201 -84
View File
@@ -30,6 +30,14 @@ const statusBadgeColor: Record<EmpStatus, string> = {
RESIGNED: 'bg-status-critical-bg text-status-critical-text', RESIGNED: 'bg-status-critical-bg text-status-critical-text',
}; };
const educationLabels: Record<string, string> = {
HIGH_SCHOOL: '고졸',
COLLEGE: '전문대',
BACHELOR: '학사',
MASTER: '석사',
DOCTOR: '박사',
};
const initialColors = ['bg-indigo-500', 'bg-purple-500', 'bg-blue-500', 'bg-green-500', 'bg-orange-500', 'bg-pink-500']; const initialColors = ['bg-indigo-500', 'bg-purple-500', 'bg-blue-500', 'bg-green-500', 'bg-orange-500', 'bg-pink-500'];
function getInitialColor(name: string, index: number) { function getInitialColor(name: string, index: number) {
@@ -41,17 +49,38 @@ interface CreateForm {
name: string; name: string;
password: string; password: string;
email: string; email: string;
phone: string;
roleCode: RoleCode; roleCode: RoleCode;
empStatus: EmpStatus; empStatus: EmpStatus;
joinedDate: string;
birthDate: string;
education: string;
major: string;
graduationDate: string;
residence: string;
} }
interface EditForm { interface EditForm {
name: string; name: string;
email: string; email: string;
phone: string;
roleCode: RoleCode; roleCode: RoleCode;
empStatus: EmpStatus; empStatus: EmpStatus;
joinedDate: string;
leftDate: string;
birthDate: string;
education: string;
major: string;
graduationDate: string;
residence: string;
} }
const emptyCreateForm: CreateForm = {
loginId: '', name: '', password: '', email: '', phone: '',
roleCode: 'EMPLOYEE', empStatus: 'ACTIVE', joinedDate: '',
birthDate: '', education: '', major: '', graduationDate: '', residence: '',
};
export default function AccountsPage() { export default function AccountsPage() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const [employees, setEmployees] = useState<Employee[]>([]); const [employees, setEmployees] = useState<Employee[]>([]);
@@ -61,11 +90,10 @@ export default function AccountsPage() {
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [targetEmployee, setTargetEmployee] = useState<Employee | null>(null); const [targetEmployee, setTargetEmployee] = useState<Employee | null>(null);
const [createForm, setCreateForm] = useState<CreateForm>({ const [createForm, setCreateForm] = useState<CreateForm>({ ...emptyCreateForm });
loginId: '', name: '', password: '', email: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE',
});
const [editForm, setEditForm] = useState<EditForm>({ const [editForm, setEditForm] = useState<EditForm>({
name: '', email: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE', name: '', email: '', phone: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE',
joinedDate: '', leftDate: '', birthDate: '', education: '', major: '', graduationDate: '', residence: '',
}); });
const [createError, setCreateError] = useState<string | null>(null); const [createError, setCreateError] = useState<string | null>(null);
const [editError, setEditError] = useState<string | null>(null); const [editError, setEditError] = useState<string | null>(null);
@@ -104,7 +132,7 @@ export default function AccountsPage() {
: employees; : employees;
const openCreate = () => { const openCreate = () => {
setCreateForm({ loginId: '', name: '', password: '', email: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE' }); setCreateForm({ ...emptyCreateForm });
setCreateError(null); setCreateError(null);
setShowCreateModal(true); setShowCreateModal(true);
}; };
@@ -130,7 +158,20 @@ export default function AccountsPage() {
const openEdit = (emp: Employee) => { const openEdit = (emp: Employee) => {
setTargetEmployee(emp); setTargetEmployee(emp);
setEditForm({ name: emp.name, email: emp.email, roleCode: emp.roleCode, empStatus: emp.empStatus }); setEditForm({
name: emp.name,
email: emp.email,
phone: emp.phone || '',
roleCode: emp.roleCode,
empStatus: emp.empStatus,
joinedDate: emp.joinedDate || '',
leftDate: emp.leftDate || '',
birthDate: emp.birthDate || '',
education: emp.education || '',
major: emp.major || '',
graduationDate: emp.graduationDate || '',
residence: emp.residence || '',
});
setEditError(null); setEditError(null);
setShowEditModal(true); setShowEditModal(true);
}; };
@@ -171,6 +212,11 @@ export default function AccountsPage() {
} }
}; };
// Shared modal field style
const fieldClass = "w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary";
const labelClass = "text-xs font-medium text-text-muted";
const selectClass = "w-full px-3 py-2 border border-border rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary";
return ( return (
<div className="p-4 sm:p-6 lg:p-8"> <div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
@@ -212,9 +258,12 @@ export default function AccountsPage() {
<thead> <thead>
<tr className="bg-gray-50/50 border-b border-border text-[10px] font-bold text-text-muted uppercase tracking-widest"> <tr className="bg-gray-50/50 border-b border-border text-[10px] font-bold text-text-muted uppercase tracking-widest">
<th className="px-4 sm:px-6 py-3">이름</th> <th className="px-4 sm:px-6 py-3">이름</th>
<th className="px-4 sm:px-6 py-3">이메일</th> <th className="px-4 sm:px-6 py-3 hidden md:table-cell">이메일</th>
<th className="px-4 sm:px-6 py-3">권한</th> <th className="px-4 sm:px-6 py-3">권한</th>
<th className="px-4 sm:px-6 py-3">상태</th> <th className="px-4 sm:px-6 py-3">상태</th>
<th className="px-4 sm:px-6 py-3 hidden lg:table-cell">생년월일</th>
<th className="px-4 sm:px-6 py-3 hidden lg:table-cell">학력</th>
<th className="px-4 sm:px-6 py-3 hidden xl:table-cell">거주지</th>
<th className="px-4 sm:px-6 py-3 hidden sm:table-cell">가입일</th> <th className="px-4 sm:px-6 py-3 hidden sm:table-cell">가입일</th>
<th className="px-4 sm:px-6 py-3 text-right">액션</th> <th className="px-4 sm:px-6 py-3 text-right">액션</th>
</tr> </tr>
@@ -230,7 +279,7 @@ export default function AccountsPage() {
<span className="font-medium text-text-main">{emp.name}</span> <span className="font-medium text-text-main">{emp.name}</span>
</div> </div>
</td> </td>
<td className="px-4 sm:px-6 py-3 text-text-muted">{emp.email || '-'}</td> <td className="px-4 sm:px-6 py-3 text-text-muted hidden md:table-cell">{emp.email || '-'}</td>
<td className="px-4 sm:px-6 py-3"> <td className="px-4 sm:px-6 py-3">
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${roleBadgeColor[emp.roleCode]}`}> <span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${roleBadgeColor[emp.roleCode]}`}>
{roleLabels[emp.roleCode]} {roleLabels[emp.roleCode]}
@@ -241,6 +290,15 @@ export default function AccountsPage() {
{statusLabels[emp.empStatus]} {statusLabels[emp.empStatus]}
</span> </span>
</td> </td>
<td className="px-4 sm:px-6 py-3 text-text-muted hidden lg:table-cell">{emp.birthDate || '-'}</td>
<td className="px-4 sm:px-6 py-3 hidden lg:table-cell">
{emp.education ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-indigo-50 text-indigo-700">
{educationLabels[emp.education] || emp.education}
</span>
) : '-'}
</td>
<td className="px-4 sm:px-6 py-3 text-text-muted hidden xl:table-cell">{emp.residence || '-'}</td>
<td className="px-4 sm:px-6 py-3 text-text-muted hidden sm:table-cell">{emp.joinedDate}</td> <td className="px-4 sm:px-6 py-3 text-text-muted hidden sm:table-cell">{emp.joinedDate}</td>
<td className="px-4 sm:px-6 py-3 text-right"> <td className="px-4 sm:px-6 py-3 text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
@@ -266,7 +324,7 @@ export default function AccountsPage() {
{/* Create Modal */} {/* Create Modal */}
{showCreateModal && ( {showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowCreateModal(false)}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowCreateModal(false)}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6" onClick={(e) => e.stopPropagation()}> <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6 max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-text-main">새 계정 생성</h3> <h3 className="text-lg font-bold text-text-main">새 계정 생성</h3>
<button onClick={() => setShowCreateModal(false)} className="p-1.5 hover:bg-gray-100 rounded-lg"> <button onClick={() => setShowCreateModal(false)} className="p-1.5 hover:bg-gray-100 rounded-lg">
@@ -274,70 +332,91 @@ export default function AccountsPage() {
</button> </button>
</div> </div>
<form onSubmit={handleCreate} className="space-y-3"> <form onSubmit={handleCreate} className="space-y-3">
<div> {/* Basic Info */}
<label className="text-xs font-medium text-text-muted">로그인 ID *</label> <div className="grid grid-cols-2 gap-3">
<input <div>
value={createForm.loginId} <label className={labelClass}>로그인 ID *</label>
onChange={(e) => setCreateForm((f) => ({ ...f, loginId: e.target.value }))} <input value={createForm.loginId} onChange={(e) => setCreateForm((f) => ({ ...f, loginId: e.target.value }))} className={fieldClass} placeholder="login_id" />
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary" </div>
placeholder="login_id" <div>
/> <label className={labelClass}>이름 *</label>
<input value={createForm.name} onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))} className={fieldClass} placeholder="홍길동" />
</div>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-text-muted">이름 *</label> <label className={labelClass}>비밀번호 *</label>
<input <input type="password" value={createForm.password} onChange={(e) => setCreateForm((f) => ({ ...f, password: e.target.value }))} className={fieldClass} placeholder="••••••••" />
value={createForm.name}
onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="홍길동"
/>
</div>
<div>
<label className="text-xs font-medium text-text-muted">비밀번호 *</label>
<input
type="password"
value={createForm.password}
onChange={(e) => setCreateForm((f) => ({ ...f, password: e.target.value }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="••••••••"
/>
</div>
<div>
<label className="text-xs font-medium text-text-muted">이메일</label>
<input
type="email"
value={createForm.email}
onChange={(e) => setCreateForm((f) => ({ ...f, email: e.target.value }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="email@example.com"
/>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="text-xs font-medium text-text-muted">권한</label> <label className={labelClass}>이메일</label>
<select <input type="email" value={createForm.email} onChange={(e) => setCreateForm((f) => ({ ...f, email: e.target.value }))} className={fieldClass} placeholder="email@example.com" />
value={createForm.roleCode} </div>
onChange={(e) => setCreateForm((f) => ({ ...f, roleCode: e.target.value as RoleCode }))} <div>
className="w-full px-3 py-2 border border-border rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary" <label className={labelClass}>전화번호</label>
> <input value={createForm.phone} onChange={(e) => setCreateForm((f) => ({ ...f, phone: e.target.value }))} className={fieldClass} placeholder="010-0000-0000" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>권한</label>
<select value={createForm.roleCode} onChange={(e) => setCreateForm((f) => ({ ...f, roleCode: e.target.value as RoleCode }))} className={selectClass}>
<option value="EMPLOYEE">일반사용자</option> <option value="EMPLOYEE">일반사용자</option>
<option value="MANAGER">본사관리자</option> <option value="MANAGER">본사관리자</option>
<option value="ADMIN">최고관리자</option> <option value="ADMIN">최고관리자</option>
</select> </select>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-text-muted">상태</label> <label className={labelClass}>상태</label>
<select <select value={createForm.empStatus} onChange={(e) => setCreateForm((f) => ({ ...f, empStatus: e.target.value as EmpStatus }))} className={selectClass}>
value={createForm.empStatus}
onChange={(e) => setCreateForm((f) => ({ ...f, empStatus: e.target.value as EmpStatus }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="ACTIVE">재직</option> <option value="ACTIVE">재직</option>
<option value="ON_LEAVE">휴직</option> <option value="ON_LEAVE">휴직</option>
<option value="RESIGNED">퇴사</option> <option value="RESIGNED">퇴사</option>
</select> </select>
</div> </div>
</div> </div>
<div>
<label className={labelClass}>가입일</label>
<input type="date" value={createForm.joinedDate} onChange={(e) => setCreateForm((f) => ({ ...f, joinedDate: e.target.value }))} className={fieldClass} />
</div>
{/* Divider */}
<div className="border-t border-border pt-3 mt-3">
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">추가 정보</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>생년월일</label>
<input type="date" value={createForm.birthDate} onChange={(e) => setCreateForm((f) => ({ ...f, birthDate: e.target.value }))} className={fieldClass} />
</div>
<div>
<label className={labelClass}>학력</label>
<select value={createForm.education} onChange={(e) => setCreateForm((f) => ({ ...f, education: e.target.value }))} className={selectClass}>
<option value="">선택하세요</option>
<option value="HIGH_SCHOOL">고졸</option>
<option value="COLLEGE">전문대</option>
<option value="BACHELOR">학사</option>
<option value="MASTER">석사</option>
<option value="DOCTOR">박사</option>
</select>
</div>
</div>
<div>
<label className={labelClass}>학과</label>
<input value={createForm.major} onChange={(e) => setCreateForm((f) => ({ ...f, major: e.target.value }))} className={fieldClass} placeholder="컴퓨터공학" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>졸업일</label>
<input type="date" value={createForm.graduationDate} onChange={(e) => setCreateForm((f) => ({ ...f, graduationDate: e.target.value }))} className={fieldClass} />
</div>
<div>
<label className={labelClass}>현재 거주지</label>
<input value={createForm.residence} onChange={(e) => setCreateForm((f) => ({ ...f, residence: e.target.value }))} className={fieldClass} placeholder="서울시 강남구" />
</div>
</div>
{createError && ( {createError && (
<div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-lg px-3 py-2 text-sm"> <div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-lg px-3 py-2 text-sm">
{createError} {createError}
@@ -359,7 +438,7 @@ export default function AccountsPage() {
{/* Edit Modal */} {/* Edit Modal */}
{showEditModal && targetEmployee && ( {showEditModal && targetEmployee && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowEditModal(false)}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowEditModal(false)}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6" onClick={(e) => e.stopPropagation()}> <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6 max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-text-main">계정 수정 — {targetEmployee.name}</h3> <h3 className="text-lg font-bold text-text-main">계정 수정 — {targetEmployee.name}</h3>
<button onClick={() => setShowEditModal(false)} className="p-1.5 hover:bg-gray-100 rounded-lg"> <button onClick={() => setShowEditModal(false)} className="p-1.5 hover:bg-gray-100 rounded-lg">
@@ -367,49 +446,87 @@ export default function AccountsPage() {
</button> </button>
</div> </div>
<form onSubmit={handleUpdate} className="space-y-3"> <form onSubmit={handleUpdate} className="space-y-3">
{/* Basic Info */}
<div> <div>
<label className="text-xs font-medium text-text-muted">이름 *</label> <label className={labelClass}>이름 *</label>
<input <input value={editForm.name} onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))} className={fieldClass} />
value={editForm.name}
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="text-xs font-medium text-text-muted">이메일</label>
<input
type="email"
value={editForm.email}
onChange={(e) => setEditForm((f) => ({ ...f, email: e.target.value }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="text-xs font-medium text-text-muted">권한</label> <label className={labelClass}>이메일</label>
<select <input type="email" value={editForm.email} onChange={(e) => setEditForm((f) => ({ ...f, email: e.target.value }))} className={fieldClass} />
value={editForm.roleCode} </div>
onChange={(e) => setEditForm((f) => ({ ...f, roleCode: e.target.value as RoleCode }))} <div>
className="w-full px-3 py-2 border border-border rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary" <label className={labelClass}>전화번호</label>
> <input value={editForm.phone} onChange={(e) => setEditForm((f) => ({ ...f, phone: e.target.value }))} className={fieldClass} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>권한</label>
<select value={editForm.roleCode} onChange={(e) => setEditForm((f) => ({ ...f, roleCode: e.target.value as RoleCode }))} className={selectClass}>
<option value="EMPLOYEE">일반사용자</option> <option value="EMPLOYEE">일반사용자</option>
<option value="MANAGER">본사관리자</option> <option value="MANAGER">본사관리자</option>
<option value="ADMIN">최고관리자</option> <option value="ADMIN">최고관리자</option>
</select> </select>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-text-muted">상태</label> <label className={labelClass}>상태</label>
<select <select value={editForm.empStatus} onChange={(e) => setEditForm((f) => ({ ...f, empStatus: e.target.value as EmpStatus }))} className={selectClass}>
value={editForm.empStatus}
onChange={(e) => setEditForm((f) => ({ ...f, empStatus: e.target.value as EmpStatus }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="ACTIVE">재직</option> <option value="ACTIVE">재직</option>
<option value="ON_LEAVE">휴직</option> <option value="ON_LEAVE">휴직</option>
<option value="RESIGNED">퇴사</option> <option value="RESIGNED">퇴사</option>
</select> </select>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>가입일</label>
<input type="date" value={editForm.joinedDate} onChange={(e) => setEditForm((f) => ({ ...f, joinedDate: e.target.value }))} className={fieldClass} />
</div>
<div>
<label className={labelClass}>퇴사일</label>
<input type="date" value={editForm.leftDate} onChange={(e) => setEditForm((f) => ({ ...f, leftDate: e.target.value }))} className={fieldClass} />
</div>
</div>
{/* Divider */}
<div className="border-t border-border pt-3 mt-3">
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">추가 정보</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>생년월일</label>
<input type="date" value={editForm.birthDate} onChange={(e) => setEditForm((f) => ({ ...f, birthDate: e.target.value }))} className={fieldClass} />
</div>
<div>
<label className={labelClass}>학력</label>
<select value={editForm.education} onChange={(e) => setEditForm((f) => ({ ...f, education: e.target.value }))} className={selectClass}>
<option value="">선택하세요</option>
<option value="HIGH_SCHOOL">고졸</option>
<option value="COLLEGE">전문대</option>
<option value="BACHELOR">학사</option>
<option value="MASTER">석사</option>
<option value="DOCTOR">박사</option>
</select>
</div>
</div>
<div>
<label className={labelClass}>학과</label>
<input value={editForm.major} onChange={(e) => setEditForm((f) => ({ ...f, major: e.target.value }))} className={fieldClass} placeholder="컴퓨터공학" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>졸업일</label>
<input type="date" value={editForm.graduationDate} onChange={(e) => setEditForm((f) => ({ ...f, graduationDate: e.target.value }))} className={fieldClass} />
</div>
<div>
<label className={labelClass}>현재 거주지</label>
<input value={editForm.residence} onChange={(e) => setEditForm((f) => ({ ...f, residence: e.target.value }))} className={fieldClass} placeholder="서울시 강남구" />
</div>
</div>
{editError && ( {editError && (
<div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-lg px-3 py-2 text-sm"> <div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-lg px-3 py-2 text-sm">
{editError} {editError}
+17 -1
View File
@@ -1,5 +1,5 @@
import apiClient from './apiClient'; import apiClient from './apiClient';
import type { ApiResponse, Employee, EmployeeSearchResult, Page, DispatchHistory, Project } from '@/types'; import type { ApiResponse, Employee, EmployeeSearchResult, Page, DispatchHistory, Project, EmergencyContact } from '@/types';
const employeeService = { const employeeService = {
login(credentials: { loginId: string; password: string }) { login(credentials: { loginId: string; password: string }) {
@@ -67,6 +67,22 @@ const employeeService = {
changePassword(currentPassword: string, newPassword: string) { changePassword(currentPassword: string, newPassword: string) {
return apiClient.put<ApiResponse<void>>('/employees/me/password', { currentPassword, newPassword }); return apiClient.put<ApiResponse<void>>('/employees/me/password', { currentPassword, newPassword });
}, },
getEmergencyContacts(empId: number) {
return apiClient.get<ApiResponse<EmergencyContact[]>>(`/employees/${empId}/emergency-contacts`);
},
createEmergencyContact(empId: number, data: Partial<EmergencyContact>) {
return apiClient.post<ApiResponse<EmergencyContact>>(`/employees/${empId}/emergency-contacts`, data);
},
updateEmergencyContact(empId: number, contactId: number, data: Partial<EmergencyContact>) {
return apiClient.put<ApiResponse<EmergencyContact>>(`/employees/${empId}/emergency-contacts/${contactId}`, data);
},
deleteEmergencyContact(empId: number, contactId: number) {
return apiClient.delete<ApiResponse<void>>(`/employees/${empId}/emergency-contacts/${contactId}`);
},
}; };
export interface PortfolioEntry { export interface PortfolioEntry {
+16
View File
@@ -49,11 +49,27 @@ export interface Employee {
loginId: string; loginId: string;
name: string; name: string;
email: string; email: string;
phone: string;
roleCode: RoleCode; roleCode: RoleCode;
empStatus: EmpStatus; empStatus: EmpStatus;
dispatchStatus: DispatchStatus; dispatchStatus: DispatchStatus;
joinedDate: string; joinedDate: string;
leftDate: string | null; leftDate: string | null;
birthDate: string | null;
education: string | null;
major: string | null;
graduationDate: string | null;
residence: string | null;
emergencyContacts?: EmergencyContact[];
}
export interface EmergencyContact {
id: number;
empId: number;
name: string;
relation: string | null;
phone: string | null;
sortOrder: number;
} }
export interface EmployeeSearchResult { export interface EmployeeSearchResult {