feat: sensitive fields removal + phone encryption + click-to-reveal + audit log

This commit is contained in:
pray4skylark
2026-06-10 16:23:39 +09:00
parent 969efe3fc3
commit cae0225f80
13 changed files with 313 additions and 257 deletions
@@ -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
);
+80 -39
View File
@@ -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.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 className="flex items-center gap-2 text-text-muted">
<Phone className="w-3.5 h-3.5 flex-shrink-0" />
{emp.phone ? (
revealedPhone ? (
<span>{revealedPhone}</span>
) : (
<button
onClick={() => setShowPhoneDialog(true)}
className="text-xs text-primary hover:underline font-medium"
>
🔒 전화번호 보기
</button>
)
) : (
<span>-</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>
);
}
+55 -52
View File
@@ -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>
<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.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>
{employee.phone ? (
<div>
{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">-</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">
+2 -110
View File
@@ -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
View File
@@ -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[];
}