From d205a447a1cf50bb49763c465a8de68cd964a3ab Mon Sep 17 00:00:00 2001 From: pray4skylark Date: Tue, 2 Jun 2026 18:11:05 +0900 Subject: [PATCH] feat: add employee fields (birthDate, education, residence) + emergency contacts --- .../controller/EmployeeController.java | 34 ++ .../com/srsystem/dto/EmployeeSummary.java | 7 +- .../java/com/srsystem/entity/Employee.java | 20 ++ .../entity/EmployeeEmergencyContact.java | 42 +++ .../EmployeeEmergencyContactRepository.java | 10 + .../com/srsystem/service/AuthService.java | 25 +- .../com/srsystem/service/EmployeeService.java | 48 ++- .../db/migration/V12__add_employee_fields.sql | 41 +++ src/app/(main)/profile/page.tsx | 313 +++++++++++++++++- src/app/(main)/settings/accounts/page.tsx | 285 +++++++++++----- src/services/employeeService.ts | 18 +- src/types/index.ts | 16 + 12 files changed, 747 insertions(+), 112 deletions(-) create mode 100644 backend/src/main/java/com/srsystem/entity/EmployeeEmergencyContact.java create mode 100644 backend/src/main/java/com/srsystem/repository/EmployeeEmergencyContactRepository.java create mode 100644 backend/src/main/resources/db/migration/V12__add_employee_fields.sql diff --git a/backend/src/main/java/com/srsystem/controller/EmployeeController.java b/backend/src/main/java/com/srsystem/controller/EmployeeController.java index e234dce..1f62bc9 100644 --- a/backend/src/main/java/com/srsystem/controller/EmployeeController.java +++ b/backend/src/main/java/com/srsystem/controller/EmployeeController.java @@ -9,6 +9,7 @@ import com.srsystem.dto.PortfolioResponse; import com.srsystem.dto.UpdateAchievementsRequest; import com.srsystem.entity.DispatchHistory; import com.srsystem.entity.Employee; +import com.srsystem.entity.EmployeeEmergencyContact; import com.srsystem.entity.enums.EmpStatus; import com.srsystem.service.DispatchHistoryService; import com.srsystem.service.EmployeeService; @@ -152,6 +153,39 @@ public class EmployeeController { return ResponseEntity.ok(ApiResponse.ok("성과가 저장되었습니다", null)); } + // ---- Emergency Contacts ---- + + @Operation(summary = "비상연락망 목록 조회", description = "직원의 비상연락망 목록을 조회합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping("/{empId}/emergency-contacts") + public ResponseEntity>> 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> 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> 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> deleteEmergencyContact(@PathVariable Long empId, @PathVariable Long contactId) { + employeeService.deleteEmergencyContact(contactId); + return ResponseEntity.ok(ApiResponse.ok("비상연락망이 삭제되었습니다", null)); + } + @Operation(summary = "비밀번호 변경", description = "현재 사용자의 비밀번호를 변경합니다") @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") @PutMapping("/me/password") diff --git a/backend/src/main/java/com/srsystem/dto/EmployeeSummary.java b/backend/src/main/java/com/srsystem/dto/EmployeeSummary.java index f615aa9..98f0f33 100644 --- a/backend/src/main/java/com/srsystem/dto/EmployeeSummary.java +++ b/backend/src/main/java/com/srsystem/dto/EmployeeSummary.java @@ -15,5 +15,10 @@ public record EmployeeSummary( EmpStatus empStatus, DispatchStatus dispatchStatus, LocalDate joinedDate, - LocalDate leftDate + LocalDate leftDate, + LocalDate birthDate, + String education, + String major, + LocalDate graduationDate, + String residence ) {} diff --git a/backend/src/main/java/com/srsystem/entity/Employee.java b/backend/src/main/java/com/srsystem/entity/Employee.java index 31f0638..b5be363 100644 --- a/backend/src/main/java/com/srsystem/entity/Employee.java +++ b/backend/src/main/java/com/srsystem/entity/Employee.java @@ -10,6 +10,7 @@ import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.SQLRestriction; import java.time.LocalDate; +import java.util.List; @Entity @Table(name = "tb_employee") @@ -60,4 +61,23 @@ public class Employee extends BaseEntity { @Column(name = "left_date") 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 emergencyContacts; } diff --git a/backend/src/main/java/com/srsystem/entity/EmployeeEmergencyContact.java b/backend/src/main/java/com/srsystem/entity/EmployeeEmergencyContact.java new file mode 100644 index 0000000..98dcdc2 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/EmployeeEmergencyContact.java @@ -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; +} diff --git a/backend/src/main/java/com/srsystem/repository/EmployeeEmergencyContactRepository.java b/backend/src/main/java/com/srsystem/repository/EmployeeEmergencyContactRepository.java new file mode 100644 index 0000000..83e13b0 --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/EmployeeEmergencyContactRepository.java @@ -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 { + List findByEmployeeEmpIdOrderBySortOrderAsc(Long empId); + void deleteByEmployeeEmpId(Long empId); +} diff --git a/backend/src/main/java/com/srsystem/service/AuthService.java b/backend/src/main/java/com/srsystem/service/AuthService.java index 4cfe3d1..bb8b0d9 100644 --- a/backend/src/main/java/com/srsystem/service/AuthService.java +++ b/backend/src/main/java/com/srsystem/service/AuthService.java @@ -23,6 +23,7 @@ public class AuthService { private final EmployeeRepository employeeRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; + private final EmployeeService employeeService; @Transactional public LoginResponse login(LoginRequest request) { @@ -36,17 +37,7 @@ public class AuthService { String accessToken = jwtTokenProvider.generateAccessToken(employee.getLoginId(), employee.getRoleCode().name()); String refreshToken = jwtTokenProvider.generateRefreshToken(employee.getLoginId()); - EmployeeSummary summary = new EmployeeSummary( - employee.getEmpId(), - employee.getName(), - employee.getEmail(), - employee.getPhone(), - employee.getRoleCode(), - employee.getEmpStatus(), - employee.getDispatchStatus(), - employee.getJoinedDate(), - employee.getLeftDate() - ); + EmployeeSummary summary = employeeService.toSummary(employee); return new LoginResponse(accessToken, refreshToken, summary); } @@ -64,17 +55,7 @@ public class AuthService { String newAccessToken = jwtTokenProvider.generateAccessToken(employee.getLoginId(), employee.getRoleCode().name()); String newRefreshToken = jwtTokenProvider.generateRefreshToken(employee.getLoginId()); - EmployeeSummary summary = new EmployeeSummary( - employee.getEmpId(), - employee.getName(), - employee.getEmail(), - employee.getPhone(), - employee.getRoleCode(), - employee.getEmpStatus(), - employee.getDispatchStatus(), - employee.getJoinedDate(), - employee.getLeftDate() - ); + EmployeeSummary summary = employeeService.toSummary(employee); return new LoginResponse(newAccessToken, newRefreshToken, summary); } diff --git a/backend/src/main/java/com/srsystem/service/EmployeeService.java b/backend/src/main/java/com/srsystem/service/EmployeeService.java index 349e934..cda1b30 100644 --- a/backend/src/main/java/com/srsystem/service/EmployeeService.java +++ b/backend/src/main/java/com/srsystem/service/EmployeeService.java @@ -4,10 +4,12 @@ import com.srsystem.dto.ChangePasswordRequest; import com.srsystem.dto.EmployeeSearchResult; import com.srsystem.dto.EmployeeSummary; import com.srsystem.entity.Employee; +import com.srsystem.entity.EmployeeEmergencyContact; import com.srsystem.entity.enums.EmpStatus; import com.srsystem.exception.BusinessException; import com.srsystem.exception.ErrorCode; import com.srsystem.repository.DispatchHistoryRepository; +import com.srsystem.repository.EmployeeEmergencyContactRepository; import com.srsystem.repository.EmployeeRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -28,6 +30,7 @@ public class EmployeeService { private final EmployeeRepository employeeRepository; private final DispatchHistoryRepository dispatchHistoryRepository; + private final EmployeeEmergencyContactRepository emergencyContactRepository; private final PasswordEncoder passwordEncoder; public Page findAll(EmpStatus status, Pageable pageable) { @@ -101,6 +104,11 @@ public class EmployeeService { employee.setDispatchStatus(updateRequest.getDispatchStatus()); employee.setJoinedDate(updateRequest.getJoinedDate()); 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()) { employee.setPassword(passwordEncoder.encode(updateRequest.getPassword())); @@ -130,10 +138,48 @@ public class EmployeeService { employee.getEmpStatus(), employee.getDispatchStatus(), employee.getJoinedDate(), - employee.getLeftDate() + employee.getLeftDate(), + employee.getBirthDate(), + employee.getEducation(), + employee.getMajor(), + employee.getGraduationDate(), + employee.getResidence() ); } + // ---- Emergency Contact CRUD ---- + + public List 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 public void changePassword(Long empId, ChangePasswordRequest request) { Employee employee = findById(empId); diff --git a/backend/src/main/resources/db/migration/V12__add_employee_fields.sql b/backend/src/main/resources/db/migration/V12__add_employee_fields.sql new file mode 100644 index 0000000..97d7cbe --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__add_employee_fields.sql @@ -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'); diff --git a/src/app/(main)/profile/page.tsx b/src/app/(main)/profile/page.tsx index 73209e5..d90acc6 100644 --- a/src/app/(main)/profile/page.tsx +++ b/src/app/(main)/profile/page.tsx @@ -2,18 +2,27 @@ import React, { useState, useEffect } from 'react'; 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 type { ApiResponse, Employee, DispatchHistory } from '@/types'; +import type { ApiResponse, Employee, DispatchHistory, EmergencyContact } from '@/types'; interface EmployeeWithId extends Employee { empId: number; } +const EDUCATION_LABELS: Record = { + HIGH_SCHOOL: '고졸', + COLLEGE: '전문대', + BACHELOR: '학사', + MASTER: '석사', + DOCTOR: '박사', +}; + export default function ProfilePage() { const router = useRouter(); const [employee, setEmployee] = useState(null); const [dispatchHistories, setDispatchHistories] = useState([]); + const [emergencyContacts, setEmergencyContacts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -25,6 +34,13 @@ export default function ProfilePage() { const [passwordSuccess, setPasswordSuccess] = useState(null); const [isChangingPassword, setIsChangingPassword] = useState(false); + // Emergency contact modal state + const [contactModalOpen, setContactModalOpen] = useState(false); + const [editingContact, setEditingContact] = useState(null); + const [contactForm, setContactForm] = useState({ name: '', relation: '', phone: '' }); + const [contactFormError, setContactFormError] = useState(null); + const [isSavingContact, setIsSavingContact] = useState(false); + useEffect(() => { loadProfile(); }, []); @@ -43,6 +59,13 @@ export default function ProfilePage() { const data = historyRes.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) { 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) => { switch (roleCode) { case 'ADMIN': @@ -122,6 +221,11 @@ export default function ProfilePage() { const formatInitial = (name: string) => name.charAt(0).toUpperCase(); + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '-'; + return dateStr; + }; + if (loading) { return (
@@ -152,7 +256,7 @@ export default function ProfilePage() { return (
-
+
{/* Header */}

내 프로필

@@ -226,6 +330,122 @@ export default function ProfilePage() {
+ {/* Personal Info */} +
+
+ +

개인 정보

+
+ +
+
+
+ + 생년월일 +
+

{formatDate(employee.birthDate)}

+
+ +
+
+ + 학력 +
+

{employee.education ? (EDUCATION_LABELS[employee.education] || employee.education) : '-'}

+
+ +
+
+ + 학과 +
+

{employee.major || '-'}

+
+ +
+
+ + 졸업일 +
+

{formatDate(employee.graduationDate)}

+
+ +
+
+ + 현재 거주지 +
+

{employee.residence || '-'}

+
+
+
+ + {/* Emergency Contacts */} +
+
+
+ +

비상 연락처

+
+ +
+ + {emergencyContacts.length === 0 ? ( +
+ +

등록된 비상 연락처가 없습니다.

+
+ ) : ( +
+ {emergencyContacts + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((contact) => ( +
+
+
+ 이름 +

{contact.name}

+
+
+ 관계 +

{contact.relation || '-'}

+
+
+ 전화번호 +

{contact.phone || '-'}

+
+
+
+ + +
+
+ ))} +
+ )} +
+ {/* Dispatch History */}
@@ -355,6 +575,93 @@ export default function ProfilePage() {
+ + {/* Emergency Contact Modal */} + {contactModalOpen && ( +
+
+
+

+ {editingContact ? '비상 연락처 수정' : '비상 연락처 추가'} +

+ +
+ +
+
+ + 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 + /> +
+ +
+ + 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="예: 배우자, 부모, 형제" + /> +
+ +
+ + 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 + /> +
+ + {contactFormError && ( +
+ + {contactFormError} +
+ )} + +
+ + +
+
+
+
+ )}
); } diff --git a/src/app/(main)/settings/accounts/page.tsx b/src/app/(main)/settings/accounts/page.tsx index 1fa95c8..5417da9 100644 --- a/src/app/(main)/settings/accounts/page.tsx +++ b/src/app/(main)/settings/accounts/page.tsx @@ -30,6 +30,14 @@ const statusBadgeColor: Record = { RESIGNED: 'bg-status-critical-bg text-status-critical-text', }; +const educationLabels: Record = { + 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']; function getInitialColor(name: string, index: number) { @@ -41,17 +49,38 @@ interface CreateForm { name: string; password: string; email: string; + phone: string; roleCode: RoleCode; empStatus: EmpStatus; + joinedDate: string; + birthDate: string; + education: string; + major: string; + graduationDate: string; + residence: string; } interface EditForm { name: string; email: string; + phone: string; roleCode: RoleCode; 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() { const user = useAuthStore((s) => s.user); const [employees, setEmployees] = useState([]); @@ -61,11 +90,10 @@ export default function AccountsPage() { const [showEditModal, setShowEditModal] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [targetEmployee, setTargetEmployee] = useState(null); - const [createForm, setCreateForm] = useState({ - loginId: '', name: '', password: '', email: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE', - }); + const [createForm, setCreateForm] = useState({ ...emptyCreateForm }); const [editForm, setEditForm] = useState({ - name: '', email: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE', + name: '', email: '', phone: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE', + joinedDate: '', leftDate: '', birthDate: '', education: '', major: '', graduationDate: '', residence: '', }); const [createError, setCreateError] = useState(null); const [editError, setEditError] = useState(null); @@ -104,7 +132,7 @@ export default function AccountsPage() { : employees; const openCreate = () => { - setCreateForm({ loginId: '', name: '', password: '', email: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE' }); + setCreateForm({ ...emptyCreateForm }); setCreateError(null); setShowCreateModal(true); }; @@ -130,7 +158,20 @@ export default function AccountsPage() { const openEdit = (emp: Employee) => { 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); 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 (
@@ -212,9 +258,12 @@ export default function AccountsPage() { 이름 - 이메일 + 이메일 권한 상태 + 생년월일 + 학력 + 거주지 가입일 액션 @@ -230,7 +279,7 @@ export default function AccountsPage() { {emp.name}
- {emp.email || '-'} + {emp.email || '-'} {roleLabels[emp.roleCode]} @@ -241,6 +290,15 @@ export default function AccountsPage() { {statusLabels[emp.empStatus]} + {emp.birthDate || '-'} + + {emp.education ? ( + + {educationLabels[emp.education] || emp.education} + + ) : '-'} + + {emp.residence || '-'} {emp.joinedDate}
@@ -266,7 +324,7 @@ export default function AccountsPage() { {/* Create Modal */} {showCreateModal && (
setShowCreateModal(false)}> -
e.stopPropagation()}> +
e.stopPropagation()}>

새 계정 생성

-
- - setCreateForm((f) => ({ ...f, loginId: 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="login_id" - /> + {/* Basic Info */} +
+
+ + setCreateForm((f) => ({ ...f, loginId: e.target.value }))} className={fieldClass} placeholder="login_id" /> +
+
+ + setCreateForm((f) => ({ ...f, name: e.target.value }))} className={fieldClass} placeholder="홍길동" /> +
- - 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="홍길동" - /> -
-
- - 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="••••••••" - /> -
-
- - 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" - /> + + setCreateForm((f) => ({ ...f, password: e.target.value }))} className={fieldClass} placeholder="••••••••" />
- - setCreateForm((f) => ({ ...f, email: e.target.value }))} className={fieldClass} placeholder="email@example.com" /> +
+
+ + setCreateForm((f) => ({ ...f, phone: e.target.value }))} className={fieldClass} placeholder="010-0000-0000" /> +
+
+
+
+ +
- - setCreateForm((f) => ({ ...f, empStatus: e.target.value as EmpStatus }))} className={selectClass}>
+
+ + setCreateForm((f) => ({ ...f, joinedDate: e.target.value }))} className={fieldClass} /> +
+ + {/* Divider */} +
+

추가 정보

+
+ +
+
+ + setCreateForm((f) => ({ ...f, birthDate: e.target.value }))} className={fieldClass} /> +
+
+ + +
+
+
+ + setCreateForm((f) => ({ ...f, major: e.target.value }))} className={fieldClass} placeholder="컴퓨터공학" /> +
+
+
+ + setCreateForm((f) => ({ ...f, graduationDate: e.target.value }))} className={fieldClass} /> +
+
+ + setCreateForm((f) => ({ ...f, residence: e.target.value }))} className={fieldClass} placeholder="서울시 강남구" /> +
+
+ {createError && (
{createError} @@ -359,7 +438,7 @@ export default function AccountsPage() { {/* Edit Modal */} {showEditModal && targetEmployee && (
setShowEditModal(false)}> -
e.stopPropagation()}> +
e.stopPropagation()}>

계정 수정 — {targetEmployee.name}

+ {/* Basic Info */}
- - 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" - /> -
-
- - 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" - /> + + setEditForm((f) => ({ ...f, name: e.target.value }))} className={fieldClass} />
- - setEditForm((f) => ({ ...f, email: e.target.value }))} className={fieldClass} /> +
+
+ + setEditForm((f) => ({ ...f, phone: e.target.value }))} className={fieldClass} /> +
+
+
+
+ +
- - setEditForm((f) => ({ ...f, empStatus: e.target.value as EmpStatus }))} className={selectClass}>
+
+
+ + setEditForm((f) => ({ ...f, joinedDate: e.target.value }))} className={fieldClass} /> +
+
+ + setEditForm((f) => ({ ...f, leftDate: e.target.value }))} className={fieldClass} /> +
+
+ + {/* Divider */} +
+

추가 정보

+
+ +
+
+ + setEditForm((f) => ({ ...f, birthDate: e.target.value }))} className={fieldClass} /> +
+
+ + +
+
+
+ + setEditForm((f) => ({ ...f, major: e.target.value }))} className={fieldClass} placeholder="컴퓨터공학" /> +
+
+
+ + setEditForm((f) => ({ ...f, graduationDate: e.target.value }))} className={fieldClass} /> +
+
+ + setEditForm((f) => ({ ...f, residence: e.target.value }))} className={fieldClass} placeholder="서울시 강남구" /> +
+
+ {editError && (
{editError} diff --git a/src/services/employeeService.ts b/src/services/employeeService.ts index ddac03f..9b625ef 100644 --- a/src/services/employeeService.ts +++ b/src/services/employeeService.ts @@ -1,5 +1,5 @@ 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 = { login(credentials: { loginId: string; password: string }) { @@ -67,6 +67,22 @@ const employeeService = { changePassword(currentPassword: string, newPassword: string) { return apiClient.put>('/employees/me/password', { currentPassword, newPassword }); }, + + getEmergencyContacts(empId: number) { + return apiClient.get>(`/employees/${empId}/emergency-contacts`); + }, + + createEmergencyContact(empId: number, data: Partial) { + return apiClient.post>(`/employees/${empId}/emergency-contacts`, data); + }, + + updateEmergencyContact(empId: number, contactId: number, data: Partial) { + return apiClient.put>(`/employees/${empId}/emergency-contacts/${contactId}`, data); + }, + + deleteEmergencyContact(empId: number, contactId: number) { + return apiClient.delete>(`/employees/${empId}/emergency-contacts/${contactId}`); + }, }; export interface PortfolioEntry { diff --git a/src/types/index.ts b/src/types/index.ts index a3fa55d..63f063b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -49,11 +49,27 @@ export interface Employee { loginId: string; name: string; email: string; + phone: string; roleCode: RoleCode; empStatus: EmpStatus; dispatchStatus: DispatchStatus; joinedDate: string; 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 {