feat: 감사 로그 API 연동, 직원 상세 모달 내 비상연락처 관리 및 프로필 링크 추가, 목록 수정 버튼 제거
This commit is contained in:
@@ -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<ApiResponse<Page<AuditLogResponse>>> getAuditLogs(
|
||||||
|
@PageableDefault(size = 50, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
|
||||||
|
Page<AuditLogResponse> logs = auditLogService.getAuditLogs(pageable);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(logs));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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<AuditLogResponse> getAuditLogs(Pageable pageable) {
|
||||||
|
Page<AuditLog> logPage = auditLogRepository.findAll(pageable);
|
||||||
|
|
||||||
|
// Fetch employee names for actorId and targetId
|
||||||
|
Set<Long> empIds = logPage.getContent().stream()
|
||||||
|
.flatMap(log -> java.util.stream.Stream.of(log.getActorId(), log.getTargetId()))
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
Map<Long, String> 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()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,8 +40,17 @@ export default function EmployeesPage() {
|
|||||||
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
|
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
|
||||||
const [detailDispatch, setDetailDispatch] = useState<any[]>([]);
|
const [detailDispatch, setDetailDispatch] = useState<any[]>([]);
|
||||||
const [detailEquipment, setDetailEquipment] = useState<any[]>([]);
|
const [detailEquipment, setDetailEquipment] = useState<any[]>([]);
|
||||||
|
const [detailEmergencyContacts, setDetailEmergencyContacts] = useState<any[]>([]);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
// Emergency Contact Form State
|
||||||
|
const [showContactModal, setShowContactModal] = useState(false);
|
||||||
|
const [contactEditing, setContactEditing] = useState<any | null>(null); // null if creating
|
||||||
|
const [contactRel, setContactRel] = useState('');
|
||||||
|
const [contactName, setContactName] = useState('');
|
||||||
|
const [contactPhone, setContactPhone] = useState('');
|
||||||
|
const [contactSubmitting, setContactSubmitting] = useState(false);
|
||||||
|
|
||||||
// Phone Reveal State
|
// Phone Reveal State
|
||||||
const [showPhoneDialog, setShowPhoneDialog] = useState(false);
|
const [showPhoneDialog, setShowPhoneDialog] = useState(false);
|
||||||
const [phoneReason, setPhoneReason] = useState('');
|
const [phoneReason, setPhoneReason] = useState('');
|
||||||
@@ -73,22 +82,34 @@ export default function EmployeesPage() {
|
|||||||
fetchEmployees();
|
fetchEmployees();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
const loadEmergencyContacts = async (empId: number) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get<any>(`/employees/${empId}/emergency-contacts`);
|
||||||
|
if (res?.data) setDetailEmergencyContacts(res.data);
|
||||||
|
} catch {
|
||||||
|
setDetailEmergencyContacts([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openDetail = async (emp: Employee) => {
|
const openDetail = async (emp: Employee) => {
|
||||||
setSelectedEmployee(emp);
|
setSelectedEmployee(emp);
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
setDetailDispatch([]);
|
setDetailDispatch([]);
|
||||||
setDetailEquipment([]);
|
setDetailEquipment([]);
|
||||||
|
setDetailEmergencyContacts([]);
|
||||||
setRevealedPhone('');
|
setRevealedPhone('');
|
||||||
setPhoneReason('');
|
setPhoneReason('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [dispatchRes, equipRes] = await Promise.all([
|
const [dispatchRes, equipRes, contactRes] = await Promise.all([
|
||||||
employeeService.getDispatchHistories(emp.empId).catch(() => null),
|
employeeService.getDispatchHistories(emp.empId).catch(() => null),
|
||||||
apiClient.get<any>(`/employees/${emp.empId}/equipment`).catch(() => null),
|
apiClient.get<any>(`/employees/${emp.empId}/equipment`).catch(() => null),
|
||||||
|
apiClient.get<any>(`/employees/${emp.empId}/emergency-contacts`).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (dispatchRes?.data) setDetailDispatch(dispatchRes.data);
|
if (dispatchRes?.data) setDetailDispatch(dispatchRes.data);
|
||||||
if (equipRes?.data) setDetailEquipment(equipRes.data);
|
if (equipRes?.data) setDetailEquipment(equipRes.data);
|
||||||
|
if (contactRes?.data) setDetailEmergencyContacts(contactRes.data);
|
||||||
} catch {
|
} catch {
|
||||||
// silently ignore
|
// silently ignore
|
||||||
}
|
}
|
||||||
@@ -100,10 +121,65 @@ export default function EmployeesPage() {
|
|||||||
setSelectedEmployee(null);
|
setSelectedEmployee(null);
|
||||||
setDetailDispatch([]);
|
setDetailDispatch([]);
|
||||||
setDetailEquipment([]);
|
setDetailEquipment([]);
|
||||||
|
setDetailEmergencyContacts([]);
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
setShowPhoneDialog(false);
|
setShowPhoneDialog(false);
|
||||||
setPhoneReason('');
|
setPhoneReason('');
|
||||||
setRevealedPhone('');
|
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('');
|
const [skillKeyword, setSkillKeyword] = useState('');
|
||||||
@@ -140,6 +216,7 @@ export default function EmployeesPage() {
|
|||||||
const emp = selectedEmployee;
|
const emp = selectedEmployee;
|
||||||
const currentDispatch = detailDispatch.find((d: any) => !d.endDate) || null;
|
const currentDispatch = detailDispatch.find((d: any) => !d.endDate) || null;
|
||||||
const activeEquipments = (detailEquipment || []).filter((eq: any) => !eq.returnedDate && eq.equipment);
|
const activeEquipments = (detailEquipment || []).filter((eq: any) => !eq.returnedDate && eq.equipment);
|
||||||
|
const canManageContacts = user?.roleCode === 'ADMIN' || user?.roleCode === 'MANAGER';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={closeDetail}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={closeDetail}>
|
||||||
@@ -149,7 +226,7 @@ export default function EmployeesPage() {
|
|||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="sticky top-0 bg-surface rounded-t-2xl border-b border-border px-6 py-4 flex items-center justify-between z-10">
|
<div className="sticky top-0 bg-surface rounded-t-2xl border-b border-border px-6 py-4 flex items-center justify-between z-10">
|
||||||
<h3 className="font-outfit text-lg font-semibold text-text-main">Employee Detail</h3>
|
<h3 className="font-outfit text-lg font-semibold text-text-main">직원 상세 정보</h3>
|
||||||
<button
|
<button
|
||||||
onClick={closeDetail}
|
onClick={closeDetail}
|
||||||
className="p-1.5 text-text-muted hover:text-text-main hover:bg-gray-100 rounded-lg transition-all"
|
className="p-1.5 text-text-muted hover:text-text-main hover:bg-gray-100 rounded-lg transition-all"
|
||||||
@@ -241,6 +318,59 @@ export default function EmployeesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Emergency Contacts Section */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h5 className="text-xs font-semibold text-text-muted uppercase tracking-wide flex items-center gap-1.5">
|
||||||
|
<Phone className="w-3.5 h-3.5" />
|
||||||
|
비상 연락망
|
||||||
|
</h5>
|
||||||
|
{canManageContacts && (
|
||||||
|
<button
|
||||||
|
onClick={() => openContactForm()}
|
||||||
|
className="text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
+ 추가
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{detailEmergencyContacts.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-2xl border border-border p-4 text-center text-xs text-text-muted">
|
||||||
|
등록된 비상연락처가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-2xl border border-border divide-y divide-gray-100 overflow-hidden">
|
||||||
|
{detailEmergencyContacts.map((contact: any) => (
|
||||||
|
<div key={contact.contactId} className="p-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-text-main">
|
||||||
|
{contact.contactName} <span className="text-text-muted font-normal">({contact.relationship})</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-muted mt-0.5">{contact.phone}</p>
|
||||||
|
</div>
|
||||||
|
{canManageContacts && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => openContactForm(contact)}
|
||||||
|
className="text-[11px] text-primary hover:underline"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<span className="text-text-muted text-[10px]">·</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteContact(contact.contactId)}
|
||||||
|
className="text-[11px] text-status-critical hover:underline"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Current Dispatch */}
|
{/* Current Dispatch */}
|
||||||
{currentDispatch && (
|
{currentDispatch && (
|
||||||
<div>
|
<div>
|
||||||
@@ -268,6 +398,16 @@ export default function EmployeesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h5 className="text-xs font-semibold text-text-muted uppercase tracking-wide mb-2">빠른 이동</h5>
|
<h5 className="text-xs font-semibold text-text-muted uppercase tracking-wide mb-2">빠른 이동</h5>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/profile/${emp.empId}`)}
|
||||||
|
className="flex items-center justify-between px-3 py-2.5 rounded-xl bg-primary/5 border border-primary/20 text-sm font-medium text-primary hover:bg-primary/10 transition-all col-span-2"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
프로필 바로가기 (관리자/통합 뷰)
|
||||||
|
</span>
|
||||||
|
<span>↗</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push(`/dispatch?empId=${emp.empId}`)}
|
onClick={() => router.push(`/dispatch?empId=${emp.empId}`)}
|
||||||
className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-white border border-border text-sm text-text-main hover:bg-gray-50 transition-all"
|
className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-white border border-border text-sm text-text-main hover:bg-gray-50 transition-all"
|
||||||
@@ -296,6 +436,15 @@ export default function EmployeesPage() {
|
|||||||
<Ticket className="w-4 h-4 text-text-muted" />
|
<Ticket className="w-4 h-4 text-text-muted" />
|
||||||
SR 티켓
|
SR 티켓
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/issues?requesterId=${emp.empId}`)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-white border border-border text-sm text-text-main hover:bg-gray-50 transition-all"
|
||||||
|
>
|
||||||
|
<Ticket className="w-4 h-4 text-text-muted" />
|
||||||
|
SR 티켓
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push(`/work-reports?userId=${emp.empId}`)}
|
onClick={() => router.push(`/work-reports?userId=${emp.empId}`)}
|
||||||
className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-white border border-border text-sm text-text-main hover:bg-gray-50 transition-all col-span-2"
|
className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-white border border-border text-sm text-text-main hover:bg-gray-50 transition-all col-span-2"
|
||||||
@@ -345,6 +494,72 @@ export default function EmployeesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Emergency Contact Modal */}
|
||||||
|
{showContactModal && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setShowContactModal(false)}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSaveContact}
|
||||||
|
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-4">
|
||||||
|
{contactEditing ? '비상연락처 수정' : '비상연락처 추가'}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3 mb-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-text-muted mb-1">관계 (예: 부, 모, 배우자)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={contactRel}
|
||||||
|
onChange={(e) => 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="관계 입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-text-muted mb-1">성명</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={contactName}
|
||||||
|
onChange={(e) => 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="성명 입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-text-muted mb-1">연락처</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={contactPhone}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowContactModal(false)}
|
||||||
|
className="px-4 py-2 rounded-xl text-sm text-text-muted hover:bg-gray-100 transition-all"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={contactSubmitting}
|
||||||
|
className="px-4 py-2 rounded-xl text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{contactSubmitting ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Phone Reveal Dialog */}
|
{/* Phone Reveal Dialog */}
|
||||||
{showPhoneDialog && (
|
{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="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => { setShowPhoneDialog(false); setPhoneReason(''); }}>
|
||||||
@@ -582,21 +797,7 @@ export default function EmployeesPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-4 sm:px-6 text-text-muted truncate max-w-[150px] sm:max-w-none">{emp.email}</td>
|
<td className="py-4 px-4 sm:px-6 text-text-muted truncate max-w-[150px] sm:max-w-none">{emp.email}</td>
|
||||||
<td className="py-4 px-4 sm:px-6">
|
<td className="py-4 px-4 sm:px-6">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-text-muted hover:text-primary transition-colors">상세 →</span>
|
<span className="text-xs text-text-muted hover:text-primary transition-colors">상세 →</span>
|
||||||
{(user?.roleCode === 'ADMIN' || user?.roleCode === 'MANAGER') && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setEditingEmployee(emp);
|
|
||||||
}}
|
|
||||||
className="px-2 py-1 bg-primary/10 text-primary hover:bg-primary/20 rounded text-xs font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
수정
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -622,22 +823,10 @@ export default function EmployeesPage() {
|
|||||||
<p className="text-xs text-text-muted truncate">{roleLabel[emp.roleCode] || emp.roleCode}</p>
|
<p className="text-xs text-text-muted truncate">{roleLabel[emp.roleCode] || emp.roleCode}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
<span className={`text-[10px] font-medium px-2.5 py-1 rounded-full ${statusColor[emp.empStatus] || 'bg-gray-100 text-gray-600'}`}>
|
<span className={`text-[10px] font-medium px-2.5 py-1 rounded-full ${statusColor[emp.empStatus] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
{emp.empStatus === 'ACTIVE' ? '재직' : emp.empStatus === 'ON_LEAVE' ? '휴직' : '퇴사'}
|
{emp.empStatus === 'ACTIVE' ? '재직' : emp.empStatus === 'ON_LEAVE' ? '휴직' : '퇴사'}
|
||||||
</span>
|
</span>
|
||||||
{(user?.roleCode === 'ADMIN' || user?.roleCode === 'MANAGER') && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setEditingEmployee(emp);
|
|
||||||
}}
|
|
||||||
className="px-2 py-1 bg-primary/10 text-primary hover:bg-primary/20 rounded text-xs font-semibold"
|
|
||||||
>
|
|
||||||
수정
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs text-text-muted">
|
<div className="grid grid-cols-2 gap-2 text-xs text-text-muted">
|
||||||
|
|||||||
@@ -35,10 +35,20 @@ interface EmployeeEntry {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RealAuditLog {
|
||||||
|
id: number;
|
||||||
|
actionType: string;
|
||||||
|
actorName: string;
|
||||||
|
targetName: string;
|
||||||
|
reason: string;
|
||||||
|
ipAddress: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuditEntry {
|
interface AuditEntry {
|
||||||
id: string;
|
id: string;
|
||||||
time: string;
|
time: string;
|
||||||
type: 'SLA' | 'ACCOUNT' | 'SYSTEM';
|
type: 'SLA' | 'ACCOUNT' | 'SYSTEM' | 'PRIVACY';
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
@@ -85,10 +95,16 @@ const typeConfig: Record<
|
|||||||
text: 'text-blue-600',
|
text: 'text-blue-600',
|
||||||
icon: <UserPlus className="w-3.5 h-3.5" />,
|
icon: <UserPlus className="w-3.5 h-3.5" />,
|
||||||
},
|
},
|
||||||
SYSTEM: {
|
PRIVACY: {
|
||||||
label: '시스템',
|
label: '개인정보 열람',
|
||||||
bg: 'bg-purple-50',
|
bg: 'bg-purple-50',
|
||||||
text: 'text-purple-600',
|
text: 'text-purple-600',
|
||||||
|
icon: <Shield className="w-3.5 h-3.5" />,
|
||||||
|
},
|
||||||
|
SYSTEM: {
|
||||||
|
label: '시스템',
|
||||||
|
bg: 'bg-gray-100',
|
||||||
|
text: 'text-gray-600',
|
||||||
icon: <Bell className="w-3.5 h-3.5" />,
|
icon: <Bell className="w-3.5 h-3.5" />,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -99,6 +115,7 @@ export default function AuditLogPage() {
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
const [employees, setEmployees] = useState<EmployeeEntry[]>([]);
|
const [employees, setEmployees] = useState<EmployeeEntry[]>([]);
|
||||||
|
const [realAuditLogs, setRealAuditLogs] = useState<RealAuditLog[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
@@ -107,15 +124,13 @@ export default function AuditLogPage() {
|
|||||||
|
|
||||||
/* ─── Data Fetching ─── */
|
/* ─── Data Fetching ─── */
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user?.roleCode !== 'ADMIN') return;
|
|
||||||
|
|
||||||
const fetchAll = async () => {
|
const fetchAll = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [notifRes, empRes] = await Promise.allSettled([
|
const [notifRes, empRes, auditRes] = await Promise.allSettled([
|
||||||
apiClient.get('/notifications') as Promise<any>,
|
apiClient.get('/notifications') as Promise<any>,
|
||||||
apiClient.get('/employees', { params: { size: 200 } }) as Promise<any>,
|
apiClient.get('/employees', { params: { size: 200 } }) as Promise<any>,
|
||||||
|
apiClient.get('/audit-logs', { params: { size: 100 } }) as Promise<any>,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (notifRes.status === 'fulfilled' && notifRes.value?.success) {
|
if (notifRes.status === 'fulfilled' && notifRes.value?.success) {
|
||||||
@@ -124,6 +139,9 @@ export default function AuditLogPage() {
|
|||||||
if (empRes.status === 'fulfilled' && empRes.value?.success) {
|
if (empRes.status === 'fulfilled' && empRes.value?.success) {
|
||||||
setEmployees(empRes.value.data?.content ?? empRes.value.data ?? []);
|
setEmployees(empRes.value.data?.content ?? empRes.value.data ?? []);
|
||||||
}
|
}
|
||||||
|
if (auditRes.status === 'fulfilled' && auditRes.value?.success) {
|
||||||
|
setRealAuditLogs(auditRes.value.data?.content ?? auditRes.value.data ?? []);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
} finally {
|
} finally {
|
||||||
@@ -131,6 +149,8 @@ export default function AuditLogPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.roleCode !== 'ADMIN') return;
|
||||||
fetchAll();
|
fetchAll();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
@@ -139,11 +159,27 @@ export default function AuditLogPage() {
|
|||||||
const auditEntries = useMemo<AuditEntry[]>(() => {
|
const auditEntries = useMemo<AuditEntry[]>(() => {
|
||||||
const entries: AuditEntry[] = [];
|
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
|
// Map notifications → audit entries
|
||||||
notifications.forEach((n) => {
|
notifications.forEach((n) => {
|
||||||
let type: AuditEntry['type'] = 'SYSTEM';
|
let type: AuditEntry['type'] = 'SYSTEM';
|
||||||
if (n.type?.includes('SLA') || n.title?.includes('SLA')) type = 'SLA';
|
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';
|
else type = 'SYSTEM';
|
||||||
|
|
||||||
entries.push({
|
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) => {
|
employees.forEach((emp) => {
|
||||||
if (emp.createdAt) {
|
if (emp.createdAt) {
|
||||||
entries.push({
|
entries.push({
|
||||||
@@ -171,7 +207,7 @@ export default function AuditLogPage() {
|
|||||||
// Sort newest first
|
// Sort newest first
|
||||||
entries.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
|
entries.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
|
||||||
return entries;
|
return entries;
|
||||||
}, [notifications, employees]);
|
}, [realAuditLogs, notifications, employees]);
|
||||||
|
|
||||||
/* ─── Filtering ─── */
|
/* ─── Filtering ─── */
|
||||||
|
|
||||||
@@ -224,18 +260,7 @@ export default function AuditLogPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => fetchAll()}
|
||||||
setLoading(true);
|
|
||||||
Promise.allSettled([
|
|
||||||
apiClient.get('/notifications') as Promise<any>,
|
|
||||||
apiClient.get('/employees', { params: { size: 200 } }) as Promise<any>,
|
|
||||||
]).then(([nRes, eRes]) => {
|
|
||||||
if (nRes.status === 'fulfilled' && nRes.value?.success)
|
|
||||||
setNotifications(nRes.value.data ?? []);
|
|
||||||
if (eRes.status === 'fulfilled' && eRes.value?.success)
|
|
||||||
setEmployees(eRes.value.data?.content ?? eRes.value.data ?? []);
|
|
||||||
}).finally(() => setLoading(false));
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl border border-border text-text-muted text-xs font-medium hover:bg-gray-100 transition-all self-start"
|
className="flex items-center gap-1.5 px-3 py-2 rounded-xl border border-border text-text-muted text-xs font-medium hover:bg-gray-100 transition-all self-start"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
|||||||
Reference in New Issue
Block a user