feat: sensitive fields removal + phone encryption + click-to-reveal + audit log
This commit is contained in:
@@ -8,6 +8,7 @@ import com.srsystem.dto.EmployeeSearchResult;
|
||||
import com.srsystem.dto.EmployeeSummary;
|
||||
import com.srsystem.dto.PortfolioResponse;
|
||||
import com.srsystem.dto.UpdateAchievementsRequest;
|
||||
import com.srsystem.entity.AuditLog;
|
||||
import com.srsystem.entity.DispatchHistory;
|
||||
import com.srsystem.entity.Employee;
|
||||
import com.srsystem.entity.EmployeeEmergencyContact;
|
||||
@@ -16,6 +17,7 @@ import com.srsystem.entity.enums.DispatchStatus;
|
||||
import com.srsystem.entity.enums.EmpStatus;
|
||||
import com.srsystem.entity.enums.RoleCode;
|
||||
import com.srsystem.util.ExcelParser;
|
||||
import com.srsystem.repository.AuditLogRepository;
|
||||
import com.srsystem.repository.EquipmentAssignmentRepository;
|
||||
import com.srsystem.service.DispatchHistoryService;
|
||||
import com.srsystem.service.EmployeeService;
|
||||
@@ -53,6 +55,7 @@ public class EmployeeController {
|
||||
private final DispatchHistoryService dispatchHistoryService;
|
||||
private final PortfolioService portfolioService;
|
||||
private final EquipmentAssignmentRepository equipmentAssignmentRepository;
|
||||
private final AuditLogRepository auditLogRepository;
|
||||
|
||||
@Operation(summary = "직원 목록 조회", description = "페이징된 직원 목록을 조회합니다. 상태 필터 옵션 포함")
|
||||
@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
|
||||
@@ -254,6 +257,29 @@ public class EmployeeController {
|
||||
return ResponseEntity.ok(ApiResponse.ok("프로필 이미지가 저장되었습니다", employeeService.update(empId, emp)));
|
||||
}
|
||||
|
||||
@Operation(summary = "전화번호 열람 요청", description = "사유를 입력하면 전화번호를 반환합니다 (감사 로그 기록)")
|
||||
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
|
||||
@PostMapping("/{empId}/request-phone")
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> requestPhone(
|
||||
@PathVariable Long empId, @RequestBody Map<String, String> body,
|
||||
@CurrentUser Employee currentUser,
|
||||
jakarta.servlet.http.HttpServletRequest request) {
|
||||
Employee target = employeeService.findById(empId);
|
||||
if (target.getPhone() == null) {
|
||||
return ResponseEntity.ok(ApiResponse.ok("등록된 전화번호가 없습니다", Map.of("phone", "")));
|
||||
}
|
||||
auditLogRepository.save(AuditLog.builder()
|
||||
.action("VIEW_PHONE")
|
||||
.targetType("EMPLOYEE")
|
||||
.targetId(empId)
|
||||
.actorId(currentUser.getEmpId())
|
||||
.reason(body.getOrDefault("reason", ""))
|
||||
.ipAddress(request.getRemoteAddr())
|
||||
.createdAt(java.time.LocalDateTime.now())
|
||||
.build());
|
||||
return ResponseEntity.ok(ApiResponse.ok(Map.of("phone", target.getPhone())));
|
||||
}
|
||||
|
||||
@Operation(summary = "비밀번호 변경", description = "현재 사용자의 비밀번호를 변경합니다")
|
||||
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
|
||||
@PutMapping("/me/password")
|
||||
@@ -293,11 +319,7 @@ public class EmployeeController {
|
||||
emp.setEmpStatus(EmpStatus.valueOf(row.getOrDefault("재직상태", "ACTIVE").toUpperCase()));
|
||||
emp.setDispatchStatus(DispatchStatus.valueOf(row.getOrDefault("파견상태", "WAITING").toUpperCase()));
|
||||
emp.setPassword("batch1234!");
|
||||
if (row.containsKey("생년월일")) emp.setBirthDate(java.time.LocalDate.parse(row.get("생년월일")));
|
||||
if (row.containsKey("학력")) emp.setEducation(row.get("학력"));
|
||||
if (row.containsKey("학교명")) emp.setSchoolName(row.get("학교명"));
|
||||
if (row.containsKey("직급")) emp.setPosition(row.get("직급"));
|
||||
if (row.containsKey("학과")) emp.setMajor(row.get("학과"));
|
||||
if (row.containsKey("입사일")) emp.setJoinedDate(java.time.LocalDate.parse(row.get("입사일")));
|
||||
return emp;
|
||||
},
|
||||
@@ -336,7 +358,7 @@ public class EmployeeController {
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@GetMapping("/batch/template")
|
||||
public ResponseEntity<byte[]> downloadTemplate() throws Exception {
|
||||
String[] headers = {"로그인ID", "이름", "이메일", "전화번호", "권한", "재직상태", "파견상태", "생년월일", "학력", "학교명", "직급", "학과", "입사일"};
|
||||
String[] headers = {"로그인ID", "이름", "이메일", "전화번호", "권한", "재직상태", "파견상태", "직급", "입사일"};
|
||||
Map<Integer, String[]> dropdowns = Map.of(
|
||||
4, new String[]{"ADMIN", "MANAGER", "EMPLOYEE"},
|
||||
5, new String[]{"ACTIVE", "ON_LEAVE", "RESIGNED"},
|
||||
|
||||
@@ -10,18 +10,10 @@ public record EmployeeSummary(
|
||||
Long empId,
|
||||
String name,
|
||||
String email,
|
||||
String phone,
|
||||
RoleCode roleCode,
|
||||
EmpStatus empStatus,
|
||||
DispatchStatus dispatchStatus,
|
||||
LocalDate joinedDate,
|
||||
LocalDate leftDate,
|
||||
LocalDate birthDate,
|
||||
String education,
|
||||
String major,
|
||||
LocalDate graduationDate,
|
||||
String residence,
|
||||
String profileImage,
|
||||
String schoolName,
|
||||
String position
|
||||
String profileImage
|
||||
) {}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.srsystem.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "tb_audit_log")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class AuditLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_audit_logs")
|
||||
@SequenceGenerator(name = "sq_audit_logs", sequenceName = "sq_audit_logs", allocationSize = 50)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "action", nullable = false, length = 50)
|
||||
private String action;
|
||||
|
||||
@Column(name = "target_type", length = 50)
|
||||
private String targetType;
|
||||
|
||||
@Column(name = "target_id")
|
||||
private Long targetId;
|
||||
|
||||
@Column(name = "actor_id")
|
||||
private Long actorId;
|
||||
|
||||
@Column(name = "reason", length = 500)
|
||||
private String reason;
|
||||
|
||||
@Column(name = "ip_address", length = 50)
|
||||
private String ipAddress;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.srsystem.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.srsystem.entity.enums.DispatchStatus;
|
||||
import com.srsystem.entity.enums.EmpStatus;
|
||||
import com.srsystem.entity.enums.RoleCode;
|
||||
import com.srsystem.util.PhoneEncryptionConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.SQLDelete;
|
||||
@@ -41,7 +41,8 @@ public class Employee extends BaseEntity {
|
||||
@Column(name = "email", length = 255)
|
||||
private String email;
|
||||
|
||||
@Column(name = "phone", length = 20)
|
||||
@Convert(converter = PhoneEncryptionConverter.class)
|
||||
@Column(name = "phone", length = 255)
|
||||
private String phone;
|
||||
|
||||
@Builder.Default
|
||||
@@ -65,18 +66,6 @@ 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 = "school_name", length = 200)
|
||||
private String schoolName;
|
||||
|
||||
@Column(name = "manager_id")
|
||||
private Long managerId;
|
||||
|
||||
@@ -86,12 +75,6 @@ public class Employee extends BaseEntity {
|
||||
@Column(name = "position", length = 50)
|
||||
private String position;
|
||||
|
||||
@Column(name = "graduation_date")
|
||||
private LocalDate graduationDate;
|
||||
|
||||
@Column(name = "residence", length = 255)
|
||||
private String residence;
|
||||
|
||||
@Column(name = "profile_image", length = 500)
|
||||
private String profileImage;
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.srsystem.repository;
|
||||
|
||||
import com.srsystem.entity.AuditLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
|
||||
}
|
||||
@@ -104,13 +104,7 @@ public class EmployeeService {
|
||||
employee.setEmail(updateRequest.getEmail());
|
||||
employee.setPhone(updateRequest.getPhone());
|
||||
employee.setJoinedDate(updateRequest.getJoinedDate());
|
||||
employee.setBirthDate(updateRequest.getBirthDate());
|
||||
employee.setEducation(updateRequest.getEducation());
|
||||
employee.setMajor(updateRequest.getMajor());
|
||||
employee.setGraduationDate(updateRequest.getGraduationDate());
|
||||
employee.setResidence(updateRequest.getResidence());
|
||||
employee.setProfileImage(updateRequest.getProfileImage());
|
||||
employee.setSchoolName(updateRequest.getSchoolName());
|
||||
employee.setPosition(updateRequest.getPosition());
|
||||
employee.setManagerId(updateRequest.getManagerId());
|
||||
employee.setBackupManagerId(updateRequest.getBackupManagerId());
|
||||
@@ -168,20 +162,12 @@ public class EmployeeService {
|
||||
employee.getEmpId(),
|
||||
employee.getName(),
|
||||
employee.getEmail(),
|
||||
employee.getPhone(),
|
||||
employee.getRoleCode(),
|
||||
employee.getEmpStatus(),
|
||||
employee.getDispatchStatus(),
|
||||
employee.getJoinedDate(),
|
||||
employee.getLeftDate(),
|
||||
employee.getBirthDate(),
|
||||
employee.getEducation(),
|
||||
employee.getMajor(),
|
||||
employee.getGraduationDate(),
|
||||
employee.getResidence(),
|
||||
employee.getProfileImage(),
|
||||
employee.getSchoolName(),
|
||||
employee.getPosition()
|
||||
employee.getProfileImage()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.srsystem.util;
|
||||
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
@Component
|
||||
@Converter
|
||||
public class PhoneEncryptionConverter implements AttributeConverter<String, String> {
|
||||
|
||||
private static final String AES = "AES";
|
||||
private static final String AES_GCM = "AES/GCM/NoPadding";
|
||||
private static final int GCM_IV_LENGTH = 12;
|
||||
private static final int GCM_TAG_LENGTH = 128;
|
||||
|
||||
private static SecretKey secretKey;
|
||||
private static final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public PhoneEncryptionConverter(@Value("${app.encryption.phone-key}") String key) {
|
||||
byte[] keyBytes = key.getBytes();
|
||||
if (keyBytes.length < 16) {
|
||||
byte[] padded = new byte[16];
|
||||
System.arraycopy(keyBytes, 0, padded, 0, Math.min(keyBytes.length, 16));
|
||||
keyBytes = padded;
|
||||
}
|
||||
secretKey = new SecretKeySpec(keyBytes, AES);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(String plaintext) {
|
||||
if (plaintext == null) return null;
|
||||
try {
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
secureRandom.nextBytes(iv);
|
||||
Cipher cipher = Cipher.getInstance(AES_GCM);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
|
||||
byte[] ciphertext = cipher.doFinal(plaintext.getBytes());
|
||||
byte[] combined = new byte[GCM_IV_LENGTH + ciphertext.length];
|
||||
System.arraycopy(iv, 0, combined, 0, GCM_IV_LENGTH);
|
||||
System.arraycopy(ciphertext, 0, combined, GCM_IV_LENGTH, ciphertext.length);
|
||||
return Base64.getEncoder().encodeToString(combined);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Phone encryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String convertToEntityAttribute(String encrypted) {
|
||||
if (encrypted == null) return null;
|
||||
try {
|
||||
byte[] combined = Base64.getDecoder().decode(encrypted);
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
byte[] ciphertext = new byte[combined.length - GCM_IV_LENGTH];
|
||||
System.arraycopy(combined, 0, iv, 0, GCM_IV_LENGTH);
|
||||
System.arraycopy(combined, GCM_IV_LENGTH, ciphertext, 0, ciphertext.length);
|
||||
Cipher cipher = Cipher.getInstance(AES_GCM);
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
|
||||
return new String(cipher.doFinal(ciphertext));
|
||||
} catch (Exception e) {
|
||||
return encrypted;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,10 @@ parking:
|
||||
weekday-end: "08:00"
|
||||
weekends: true
|
||||
|
||||
app:
|
||||
encryption:
|
||||
phone-key: ${PHONE_ENCRYPTION_KEY:change-me-to-32-chars!!}
|
||||
|
||||
file:
|
||||
upload-dir: ./uploads
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE tb_employee
|
||||
DROP COLUMN IF EXISTS birth_date,
|
||||
DROP COLUMN IF EXISTS residence,
|
||||
DROP COLUMN IF EXISTS education,
|
||||
DROP COLUMN IF EXISTS school_name,
|
||||
DROP COLUMN IF EXISTS major,
|
||||
DROP COLUMN IF EXISTS graduation_date;
|
||||
|
||||
CREATE SEQUENCE IF NOT EXISTS sq_audit_logs INCREMENT BY 50;
|
||||
|
||||
CREATE TABLE tb_audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
target_type VARCHAR(50),
|
||||
target_id BIGINT,
|
||||
actor_id BIGINT,
|
||||
reason VARCHAR(500),
|
||||
ip_address VARCHAR(50),
|
||||
created_at TIMESTAMP
|
||||
);
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Search, X, Camera, Phone, Mail, Calendar, MapPin, GraduationCap, Briefcase, Ticket, FileText, ExternalLink } from 'lucide-react';
|
||||
import { Search, X, Camera, Phone, Mail, Briefcase, Ticket, FileText, ExternalLink } from 'lucide-react';
|
||||
import employeeService from '@/services/employeeService';
|
||||
import apiClient from '@/services/apiClient';
|
||||
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||
@@ -40,6 +40,11 @@ export default function EmployeesPage() {
|
||||
const [detailEquipment, setDetailEquipment] = useState<any[]>([]);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// Phone Reveal State
|
||||
const [showPhoneDialog, setShowPhoneDialog] = useState(false);
|
||||
const [phoneReason, setPhoneReason] = useState('');
|
||||
const [revealedPhone, setRevealedPhone] = useState('');
|
||||
|
||||
const fetchEmployees = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -71,6 +76,8 @@ export default function EmployeesPage() {
|
||||
setDetailLoading(true);
|
||||
setDetailDispatch([]);
|
||||
setDetailEquipment([]);
|
||||
setRevealedPhone('');
|
||||
setPhoneReason('');
|
||||
|
||||
try {
|
||||
const [dispatchRes, equipRes] = await Promise.all([
|
||||
@@ -92,6 +99,9 @@ export default function EmployeesPage() {
|
||||
setDetailDispatch([]);
|
||||
setDetailEquipment([]);
|
||||
setDetailLoading(false);
|
||||
setShowPhoneDialog(false);
|
||||
setPhoneReason('');
|
||||
setRevealedPhone('');
|
||||
};
|
||||
|
||||
const [skillKeyword, setSkillKeyword] = useState('');
|
||||
@@ -123,14 +133,6 @@ export default function EmployeesPage() {
|
||||
return new Date(dateStr).toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
};
|
||||
|
||||
const educationLabel: Record<string, string> = {
|
||||
HIGH_SCHOOL: '고졸',
|
||||
ASSOCIATE: '전문대',
|
||||
BACHELOR: '학사',
|
||||
MASTER: '석사',
|
||||
DOCTORATE: '박사',
|
||||
};
|
||||
|
||||
// ── Detail Modal ──
|
||||
if (selectedEmployee) {
|
||||
const emp = selectedEmployee;
|
||||
@@ -217,36 +219,23 @@ export default function EmployeesPage() {
|
||||
<span className="truncate">{emp.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{emp.phone && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<Phone className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>{emp.phone}</span>
|
||||
</div>
|
||||
{emp.phone ? (
|
||||
revealedPhone ? (
|
||||
<span>{revealedPhone}</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowPhoneDialog(true)}
|
||||
className="text-xs text-primary hover:underline font-medium"
|
||||
>
|
||||
🔒 전화번호 보기
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
{emp.birthDate && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<Calendar className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>{formatDate(emp.birthDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
{emp.residence && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate">{emp.residence}</span>
|
||||
</div>
|
||||
)}
|
||||
{emp.education && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<GraduationCap className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>{educationLabel[emp.education] || emp.education}{emp.schoolName ? ` · ${emp.schoolName}` : ''}{emp.major ? ` · ${emp.major}` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
{emp.graduationDate && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<Calendar className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>졸업 {formatDate(emp.graduationDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -353,6 +342,58 @@ export default function EmployeesPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone Reveal Dialog */}
|
||||
{showPhoneDialog && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => { setShowPhoneDialog(false); setPhoneReason(''); }}>
|
||||
<div className="bg-surface rounded-2xl shadow-xl max-w-sm w-full p-6 animate-in fade-in zoom-in duration-200" onClick={(e) => e.stopPropagation()}>
|
||||
<h4 className="font-outfit text-lg font-semibold text-text-main mb-2">전화번호 조회</h4>
|
||||
<p className="text-sm text-text-muted mb-4">조회 사유를 입력해주세요.</p>
|
||||
<textarea
|
||||
value={phoneReason}
|
||||
onChange={(e) => setPhoneReason(e.target.value)}
|
||||
className="w-full border border-border rounded-xl p-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none h-24 mb-4"
|
||||
placeholder="예: 업무 관련 연락 필요"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => { setShowPhoneDialog(false); setPhoneReason(''); }}
|
||||
className="px-4 py-2 rounded-xl text-sm text-text-muted hover:bg-gray-100 transition-all"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!selectedEmployee || !phoneReason.trim()) return;
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const res = await fetch(`/api/v1/employees/${selectedEmployee.empId}/request-phone`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ reason: phoneReason }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.data?.phone) {
|
||||
setRevealedPhone(data.data.phone);
|
||||
setShowPhoneDialog(false);
|
||||
setPhoneReason('');
|
||||
}
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}}
|
||||
disabled={!phoneReason.trim()}
|
||||
className="px-4 py-2 rounded-xl text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { UserCircle, Mail, Briefcase, Calendar, Lock, Shield, CheckCircle, XCircle, AlertCircle, Phone, Plus, Pencil, Trash2, X, Save, GraduationCap, MapPin, Camera, Loader2 } from 'lucide-react';
|
||||
import { UserCircle, Mail, Briefcase, Calendar, Lock, Shield, CheckCircle, XCircle, AlertCircle, Phone, Plus, Pencil, Trash2, X, Save, Camera, Loader2 } from 'lucide-react';
|
||||
import employeeService from '@/services/employeeService';
|
||||
import type { ApiResponse, Employee, DispatchHistory, EmergencyContact } from '@/types';
|
||||
|
||||
@@ -10,14 +10,6 @@ interface EmployeeWithId extends Employee {
|
||||
empId: number;
|
||||
}
|
||||
|
||||
const EDUCATION_LABELS: Record<string, string> = {
|
||||
HIGH_SCHOOL: '고졸',
|
||||
COLLEGE: '전문대',
|
||||
BACHELOR: '학사',
|
||||
MASTER: '석사',
|
||||
DOCTOR: '박사',
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
const [employee, setEmployee] = useState<EmployeeWithId | null>(null);
|
||||
@@ -41,6 +33,11 @@ export default function ProfilePage() {
|
||||
const [contactFormError, setContactFormError] = useState<string | null>(null);
|
||||
const [isSavingContact, setIsSavingContact] = useState(false);
|
||||
|
||||
// Phone reveal state
|
||||
const [showPhoneDialog, setShowPhoneDialog] = useState(false);
|
||||
const [phoneReason, setPhoneReason] = useState('');
|
||||
const [revealedPhone, setRevealedPhone] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, []);
|
||||
@@ -365,50 +362,23 @@ export default function ProfilePage() {
|
||||
<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>
|
||||
<Phone className="w-4 h-4" />
|
||||
<span>전화번호</span>
|
||||
</div>
|
||||
<p className="text-text-main font-medium">{formatDate(employee.birthDate)}</p>
|
||||
</div>
|
||||
|
||||
{employee.phone ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
|
||||
<GraduationCap className="w-4 h-4" />
|
||||
<span>학력</span>
|
||||
{revealedPhone ? (
|
||||
<p className="text-text-main font-medium font-mono text-sm">{revealedPhone}</p>
|
||||
) : (
|
||||
<button onClick={() => setShowPhoneDialog(true)}
|
||||
className="text-xs text-primary underline flex items-center gap-1">
|
||||
🔒 전화번호 보기
|
||||
</button>
|
||||
)}
|
||||
</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.schoolName || '-'}</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>
|
||||
) : (
|
||||
<p className="text-text-main font-medium">-</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -609,6 +579,39 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone Reveal Dialog */}
|
||||
{showPhoneDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowPhoneDialog(false)}>
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-sm w-full mx-4 p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-bold text-text-main mb-1">전화번호 열람</h3>
|
||||
<p className="text-xs text-text-muted mb-3">열람 사유가 감사 로그에 기록됩니다.</p>
|
||||
<textarea value={phoneReason} onChange={(e) => setPhoneReason(e.target.value)} rows={2}
|
||||
className="w-full px-3 py-2 border border-border rounded-lg text-sm mb-4 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="열람 사유를 입력하세요" />
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={() => { setShowPhoneDialog(false); setPhoneReason(''); }}
|
||||
className="flex-1 px-4 py-2 rounded-xl border border-border text-sm font-medium text-text-muted hover:bg-gray-100">취소</button>
|
||||
<button onClick={async () => {
|
||||
if (!phoneReason.trim()) return;
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const res = await fetch(`/api/v1/employees/${employee.empId}/request-phone`, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json', Authorization: `Bearer ${token}`},
|
||||
body: JSON.stringify({ reason: phoneReason }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) setRevealedPhone(json.data.phone);
|
||||
setShowPhoneDialog(false);
|
||||
setPhoneReason('');
|
||||
setTimeout(() => setRevealedPhone(''), 5000);
|
||||
} catch { alert('열람 요청 실패'); }
|
||||
}} disabled={!phoneReason.trim()}
|
||||
className="flex-1 px-4 py-2 rounded-xl bg-primary text-white text-sm font-semibold disabled:opacity-50">열람</button>
|
||||
</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">
|
||||
|
||||
@@ -30,14 +30,6 @@ const statusBadgeColor: Record<EmpStatus, string> = {
|
||||
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'];
|
||||
|
||||
function getInitialColor(name: string, index: number) {
|
||||
@@ -53,13 +45,7 @@ interface CreateForm {
|
||||
roleCode: RoleCode;
|
||||
empStatus: EmpStatus;
|
||||
joinedDate: string;
|
||||
birthDate: string;
|
||||
education: string;
|
||||
schoolName: string;
|
||||
position: string;
|
||||
major: string;
|
||||
graduationDate: string;
|
||||
residence: string;
|
||||
}
|
||||
|
||||
interface EditForm {
|
||||
@@ -70,21 +56,15 @@ interface EditForm {
|
||||
empStatus: EmpStatus;
|
||||
joinedDate: string;
|
||||
leftDate: string;
|
||||
birthDate: string;
|
||||
education: string;
|
||||
schoolName: string;
|
||||
position: string;
|
||||
managerId: string;
|
||||
backupManagerId: string;
|
||||
major: string;
|
||||
graduationDate: string;
|
||||
residence: string;
|
||||
}
|
||||
|
||||
const emptyCreateForm: CreateForm = {
|
||||
loginId: '', name: '', password: '', email: '', phone: '',
|
||||
roleCode: 'EMPLOYEE', empStatus: 'ACTIVE', joinedDate: '',
|
||||
birthDate: '', education: '', schoolName: '', position: '', major: '', graduationDate: '', residence: '',
|
||||
position: '',
|
||||
};
|
||||
|
||||
export default function AccountsPage() {
|
||||
@@ -99,7 +79,7 @@ export default function AccountsPage() {
|
||||
const [createForm, setCreateForm] = useState<CreateForm>({ ...emptyCreateForm });
|
||||
const [editForm, setEditForm] = useState<EditForm>({
|
||||
name: '', email: '', phone: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE',
|
||||
joinedDate: '', leftDate: '', birthDate: '', education: '', schoolName: '', position: '', managerId: '', backupManagerId: '', major: '', graduationDate: '', residence: '',
|
||||
joinedDate: '', leftDate: '', position: '', managerId: '', backupManagerId: '',
|
||||
});
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
@@ -175,15 +155,9 @@ export default function AccountsPage() {
|
||||
empStatus: emp.empStatus,
|
||||
joinedDate: emp.joinedDate || '',
|
||||
leftDate: emp.leftDate || '',
|
||||
birthDate: emp.birthDate || '',
|
||||
education: emp.education || '',
|
||||
schoolName: emp.schoolName || '',
|
||||
position: emp.position || '',
|
||||
managerId: String(emp.managerId ?? ''),
|
||||
backupManagerId: String(emp.backupManagerId ?? ''),
|
||||
major: emp.major || '',
|
||||
graduationDate: emp.graduationDate || '',
|
||||
residence: emp.residence || '',
|
||||
});
|
||||
setEditError(null);
|
||||
setShowEditModal(true);
|
||||
@@ -277,10 +251,7 @@ export default function AccountsPage() {
|
||||
<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 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 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 text-right">액션</th>
|
||||
</tr>
|
||||
@@ -311,16 +282,7 @@ export default function AccountsPage() {
|
||||
{statusLabels[emp.empStatus]}
|
||||
</span>
|
||||
</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.position || '-'}</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-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
@@ -407,27 +369,6 @@ export default function AccountsPage() {
|
||||
<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.schoolName} onChange={(e) => setCreateForm((f) => ({ ...f, schoolName: e.target.value }))} className={fieldClass} placeholder="서울대학교" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>직급</label>
|
||||
<select value={createForm.position} onChange={(e) => setCreateForm((f) => ({ ...f, position: e.target.value }))} className={selectClass}>
|
||||
@@ -441,20 +382,6 @@ export default function AccountsPage() {
|
||||
<option value="DIRECTOR">이사</option>
|
||||
</select>
|
||||
</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 && (
|
||||
<div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-lg px-3 py-2 text-sm">
|
||||
@@ -574,27 +501,6 @@ export default function AccountsPage() {
|
||||
</div>
|
||||
</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.schoolName} onChange={(e) => setEditForm((f) => ({ ...f, schoolName: e.target.value }))} className={fieldClass} placeholder="서울대학교" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>직급</label>
|
||||
<select value={editForm.position} onChange={(e) => setEditForm((f) => ({ ...f, position: e.target.value }))} className={selectClass}>
|
||||
@@ -626,20 +532,6 @@ export default function AccountsPage() {
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
|
||||
{/* Password Reset */}
|
||||
<div className="border-t border-border pt-3 mt-3">
|
||||
|
||||
+1
-7
@@ -55,16 +55,10 @@ export interface Employee {
|
||||
dispatchStatus: DispatchStatus;
|
||||
joinedDate: string;
|
||||
leftDate: string | null;
|
||||
birthDate: string | null;
|
||||
education: string | null;
|
||||
schoolName: string | null;
|
||||
position: string | null;
|
||||
profileImage: string | null;
|
||||
managerId?: number;
|
||||
backupManagerId?: number;
|
||||
major: string | null;
|
||||
graduationDate: string | null;
|
||||
residence: string | null;
|
||||
profileImage: string | null;
|
||||
emergencyContacts?: EmergencyContact[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user