diff --git a/backend/src/main/java/com/srsystem/controller/AuditLogController.java b/backend/src/main/java/com/srsystem/controller/AuditLogController.java new file mode 100644 index 0000000..8f8ed41 --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/AuditLogController.java @@ -0,0 +1,37 @@ +package com.srsystem.controller; + +import com.srsystem.dto.ApiResponse; +import com.srsystem.dto.AuditLogResponse; +import com.srsystem.service.AuditLogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Audit Log", description = "감사 로그 조회 API") +@RestController +@RequestMapping("/api/v1/audit-logs") +@RequiredArgsConstructor +@SecurityRequirement(name = "bearerAuth") +public class AuditLogController { + + private final AuditLogService auditLogService; + + @Operation(summary = "감사 로그 목록 조회", description = "개인정보 열람 및 감사 로그 내역을 조회합니다 (ADMIN전용)") + @PreAuthorize("hasRole('ADMIN')") + @GetMapping + public ResponseEntity>> getAuditLogs( + @PageableDefault(size = 50, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + Page logs = auditLogService.getAuditLogs(pageable); + return ResponseEntity.ok(ApiResponse.ok(logs)); + } +} diff --git a/backend/src/main/java/com/srsystem/dto/AuditLogResponse.java b/backend/src/main/java/com/srsystem/dto/AuditLogResponse.java new file mode 100644 index 0000000..53e31ea --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/AuditLogResponse.java @@ -0,0 +1,16 @@ +package com.srsystem.dto; + +import java.time.LocalDateTime; + +public record AuditLogResponse( + Long id, + String action, + String targetType, + Long targetId, + String targetName, + Long actorId, + String actorName, + String reason, + String ipAddress, + LocalDateTime createdAt +) {} diff --git a/backend/src/main/java/com/srsystem/service/AuditLogService.java b/backend/src/main/java/com/srsystem/service/AuditLogService.java new file mode 100644 index 0000000..55dc898 --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/AuditLogService.java @@ -0,0 +1,51 @@ +package com.srsystem.service; + +import com.srsystem.dto.AuditLogResponse; +import com.srsystem.entity.AuditLog; +import com.srsystem.entity.Employee; +import com.srsystem.repository.AuditLogRepository; +import com.srsystem.repository.EmployeeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuditLogService { + + private final AuditLogRepository auditLogRepository; + private final EmployeeRepository employeeRepository; + + public Page getAuditLogs(Pageable pageable) { + Page logPage = auditLogRepository.findAll(pageable); + + // Fetch employee names for actorId and targetId + Set empIds = logPage.getContent().stream() + .flatMap(log -> java.util.stream.Stream.of(log.getActorId(), log.getTargetId())) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toSet()); + + Map empNameMap = employeeRepository.findAllById(empIds).stream() + .collect(Collectors.toMap(Employee::getEmpId, Employee::getName)); + + return logPage.map(log -> new AuditLogResponse( + log.getId(), + log.getAction(), + log.getTargetType(), + log.getTargetId(), + log.getTargetId() != null ? empNameMap.getOrDefault(log.getTargetId(), "Emp #" + log.getTargetId()) : null, + log.getActorId(), + log.getActorId() != null ? empNameMap.getOrDefault(log.getActorId(), "Emp #" + log.getActorId()) : null, + log.getReason(), + log.getIpAddress(), + log.getCreatedAt() + )); + } +} diff --git a/src/app/(main)/employees/page.tsx b/src/app/(main)/employees/page.tsx index 57119ad..04daba7 100644 --- a/src/app/(main)/employees/page.tsx +++ b/src/app/(main)/employees/page.tsx @@ -40,8 +40,17 @@ export default function EmployeesPage() { const [editingEmployee, setEditingEmployee] = useState(null); const [detailDispatch, setDetailDispatch] = useState([]); const [detailEquipment, setDetailEquipment] = useState([]); + const [detailEmergencyContacts, setDetailEmergencyContacts] = useState([]); const [detailLoading, setDetailLoading] = useState(false); + // Emergency Contact Form State + const [showContactModal, setShowContactModal] = useState(false); + const [contactEditing, setContactEditing] = useState(null); // null if creating + const [contactRel, setContactRel] = useState(''); + const [contactName, setContactName] = useState(''); + const [contactPhone, setContactPhone] = useState(''); + const [contactSubmitting, setContactSubmitting] = useState(false); + // Phone Reveal State const [showPhoneDialog, setShowPhoneDialog] = useState(false); const [phoneReason, setPhoneReason] = useState(''); @@ -73,22 +82,34 @@ export default function EmployeesPage() { fetchEmployees(); }, [user]); + const loadEmergencyContacts = async (empId: number) => { + try { + const res = await apiClient.get(`/employees/${empId}/emergency-contacts`); + if (res?.data) setDetailEmergencyContacts(res.data); + } catch { + setDetailEmergencyContacts([]); + } + }; + const openDetail = async (emp: Employee) => { setSelectedEmployee(emp); setDetailLoading(true); setDetailDispatch([]); setDetailEquipment([]); + setDetailEmergencyContacts([]); setRevealedPhone(''); setPhoneReason(''); try { - const [dispatchRes, equipRes] = await Promise.all([ + const [dispatchRes, equipRes, contactRes] = await Promise.all([ employeeService.getDispatchHistories(emp.empId).catch(() => null), apiClient.get(`/employees/${emp.empId}/equipment`).catch(() => null), + apiClient.get(`/employees/${emp.empId}/emergency-contacts`).catch(() => null), ]); if (dispatchRes?.data) setDetailDispatch(dispatchRes.data); if (equipRes?.data) setDetailEquipment(equipRes.data); + if (contactRes?.data) setDetailEmergencyContacts(contactRes.data); } catch { // silently ignore } @@ -100,10 +121,65 @@ export default function EmployeesPage() { setSelectedEmployee(null); setDetailDispatch([]); setDetailEquipment([]); + setDetailEmergencyContacts([]); setDetailLoading(false); setShowPhoneDialog(false); setPhoneReason(''); setRevealedPhone(''); + setShowContactModal(false); + setContactEditing(null); + }; + + const openContactForm = (contact?: any) => { + if (contact) { + setContactEditing(contact); + setContactRel(contact.relationship || ''); + setContactName(contact.contactName || ''); + setContactPhone(contact.phone || ''); + } else { + setContactEditing(null); + setContactRel(''); + setContactName(''); + setContactPhone(''); + } + setShowContactModal(true); + }; + + const handleSaveContact = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedEmployee || !contactRel.trim() || !contactName.trim() || !contactPhone.trim()) return; + + setContactSubmitting(true); + try { + const payload = { + relationship: contactRel.trim(), + contactName: contactName.trim(), + phone: contactPhone.trim(), + }; + + if (contactEditing?.contactId) { + await apiClient.put(`/employees/${selectedEmployee.empId}/emergency-contacts/${contactEditing.contactId}`, payload); + } else { + await apiClient.post(`/employees/${selectedEmployee.empId}/emergency-contacts`, payload); + } + + await loadEmergencyContacts(selectedEmployee.empId); + setShowContactModal(false); + } catch (err) { + console.error('Failed to save emergency contact', err); + } finally { + setContactSubmitting(false); + } + }; + + const handleDeleteContact = async (contactId: number) => { + if (!selectedEmployee || !confirm('이 비상연락처를 삭제하시겠습니까?')) return; + try { + await apiClient.delete(`/employees/${selectedEmployee.empId}/emergency-contacts/${contactId}`); + await loadEmergencyContacts(selectedEmployee.empId); + } catch (err) { + console.error('Failed to delete emergency contact', err); + } }; const [skillKeyword, setSkillKeyword] = useState(''); @@ -140,6 +216,7 @@ export default function EmployeesPage() { const emp = selectedEmployee; const currentDispatch = detailDispatch.find((d: any) => !d.endDate) || null; const activeEquipments = (detailEquipment || []).filter((eq: any) => !eq.returnedDate && eq.equipment); + const canManageContacts = user?.roleCode === 'ADMIN' || user?.roleCode === 'MANAGER'; return (
@@ -149,7 +226,7 @@ export default function EmployeesPage() { > {/* Header */}
-

Employee Detail

+

직원 상세 정보

+ {/* Emergency Contacts Section */} +
+
+
+ + 비상 연락망 +
+ {canManageContacts && ( + + )} +
+ {detailEmergencyContacts.length === 0 ? ( +
+ 등록된 비상연락처가 없습니다. +
+ ) : ( +
+ {detailEmergencyContacts.map((contact: any) => ( +
+
+

+ {contact.contactName} ({contact.relationship}) +

+

{contact.phone}

+
+ {canManageContacts && ( +
+ + · + +
+ )} +
+ ))} +
+ )} +
+ {/* Current Dispatch */} {currentDispatch && (
@@ -268,6 +398,16 @@ export default function EmployeesPage() {
빠른 이동
+ +
+
+
+ {/* Emergency Contact Modal */} + {showContactModal && ( +
setShowContactModal(false)}> +
e.stopPropagation()} + > +

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

+
+
+ + setContactRel(e.target.value)} + className="w-full border border-border rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="관계 입력" + /> +
+
+ + setContactName(e.target.value)} + className="w-full border border-border rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="성명 입력" + /> +
+
+ + setContactPhone(e.target.value)} + className="w-full border border-border rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="010-0000-0000" + /> +
+
+
+ + +
+
+
+ )} + {/* Phone Reveal Dialog */} {showPhoneDialog && (
{ setShowPhoneDialog(false); setPhoneReason(''); }}> @@ -582,21 +797,7 @@ export default function EmployeesPage() { {emp.email} -
- 상세 → - {(user?.roleCode === 'ADMIN' || user?.roleCode === 'MANAGER') && ( - - )} -
+ 상세 → ))} @@ -622,22 +823,10 @@ export default function EmployeesPage() {

{roleLabel[emp.roleCode] || emp.roleCode}

-
+
{emp.empStatus === 'ACTIVE' ? '재직' : emp.empStatus === 'ON_LEAVE' ? '휴직' : '퇴사'} - {(user?.roleCode === 'ADMIN' || user?.roleCode === 'MANAGER') && ( - - )}
diff --git a/src/app/(main)/settings/audit/page.tsx b/src/app/(main)/settings/audit/page.tsx index b827e7b..3f0c259 100644 --- a/src/app/(main)/settings/audit/page.tsx +++ b/src/app/(main)/settings/audit/page.tsx @@ -35,10 +35,20 @@ interface EmployeeEntry { createdAt?: string; } +interface RealAuditLog { + id: number; + actionType: string; + actorName: string; + targetName: string; + reason: string; + ipAddress: string; + createdAt: string; +} + interface AuditEntry { id: string; time: string; - type: 'SLA' | 'ACCOUNT' | 'SYSTEM'; + type: 'SLA' | 'ACCOUNT' | 'SYSTEM' | 'PRIVACY'; title: string; message: string; } @@ -85,10 +95,16 @@ const typeConfig: Record< text: 'text-blue-600', icon: , }, - SYSTEM: { - label: '시스템', + PRIVACY: { + label: '개인정보 열람', bg: 'bg-purple-50', text: 'text-purple-600', + icon: , + }, + SYSTEM: { + label: '시스템', + bg: 'bg-gray-100', + text: 'text-gray-600', icon: , }, }; @@ -99,6 +115,7 @@ export default function AuditLogPage() { const user = useAuthStore((s) => s.user); const [notifications, setNotifications] = useState([]); const [employees, setEmployees] = useState([]); + const [realAuditLogs, setRealAuditLogs] = useState([]); const [loading, setLoading] = useState(true); // Filters @@ -107,30 +124,33 @@ export default function AuditLogPage() { /* ─── Data Fetching ─── */ + const fetchAll = async () => { + setLoading(true); + try { + const [notifRes, empRes, auditRes] = await Promise.allSettled([ + apiClient.get('/notifications') as Promise, + apiClient.get('/employees', { params: { size: 200 } }) as Promise, + apiClient.get('/audit-logs', { params: { size: 100 } }) as Promise, + ]); + + if (notifRes.status === 'fulfilled' && notifRes.value?.success) { + setNotifications(notifRes.value.data ?? []); + } + if (empRes.status === 'fulfilled' && empRes.value?.success) { + setEmployees(empRes.value.data?.content ?? empRes.value.data ?? []); + } + if (auditRes.status === 'fulfilled' && auditRes.value?.success) { + setRealAuditLogs(auditRes.value.data?.content ?? auditRes.value.data ?? []); + } + } catch { + // silent + } finally { + setLoading(false); + } + }; + useEffect(() => { if (user?.roleCode !== 'ADMIN') return; - - const fetchAll = async () => { - setLoading(true); - try { - const [notifRes, empRes] = await Promise.allSettled([ - apiClient.get('/notifications') as Promise, - apiClient.get('/employees', { params: { size: 200 } }) as Promise, - ]); - - if (notifRes.status === 'fulfilled' && notifRes.value?.success) { - setNotifications(notifRes.value.data ?? []); - } - if (empRes.status === 'fulfilled' && empRes.value?.success) { - setEmployees(empRes.value.data?.content ?? empRes.value.data ?? []); - } - } catch { - // silent - } finally { - setLoading(false); - } - }; - fetchAll(); }, [user]); @@ -139,11 +159,27 @@ export default function AuditLogPage() { const auditEntries = useMemo(() => { const entries: AuditEntry[] = []; + // Map real audit logs from DB + realAuditLogs.forEach((log) => { + let type: AuditEntry['type'] = 'PRIVACY'; + let title = `로그: ${log.actionType}`; + if (log.actionType === 'VIEW_PHONE') { + type = 'PRIVACY'; + title = `비상 연락처 열람 — [${log.actorName}] → [${log.targetName}]`; + } + entries.push({ + id: `db-audit-${log.id}`, + time: log.createdAt, + type, + title, + message: log.reason ? `열람 사유: ${log.reason} ${log.ipAddress ? `(IP: ${log.ipAddress})` : ''}` : `IP: ${log.ipAddress}`, + }); + }); + // Map notifications → audit entries notifications.forEach((n) => { let type: AuditEntry['type'] = 'SYSTEM'; if (n.type?.includes('SLA') || n.title?.includes('SLA')) type = 'SLA'; - else if (n.type?.includes('NOTIF') || n.type?.includes('TICKET')) type = 'SYSTEM'; else type = 'SYSTEM'; entries.push({ @@ -155,7 +191,7 @@ export default function AuditLogPage() { }); }); - // Map employee creations (use createdAt if available, else synthetic) + // Map employee creations employees.forEach((emp) => { if (emp.createdAt) { entries.push({ @@ -171,7 +207,7 @@ export default function AuditLogPage() { // Sort newest first entries.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()); return entries; - }, [notifications, employees]); + }, [realAuditLogs, notifications, employees]); /* ─── Filtering ─── */ @@ -224,18 +260,7 @@ export default function AuditLogPage() {